From 7cdd688c61f54f19fd451120442835da94bce0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Wed, 8 Jan 2025 21:42:44 +0100 Subject: [PATCH] Finalize Dev Services for OIDC --- .github/native-tests.json | 2 +- bom/application/pom.xml | 5 + ...ui-oidc-dev-svc-login-for-custom-users.png | Bin 0 -> 10688 bytes .../images/dev-ui-oidc-dev-svc-login-page.png | Bin 0 -> 7044 bytes .../security-openid-connect-dev-services.adoc | 43 +- .../keycloak/KeycloakDevServicesConfig.java | 6 - .../KeycloakDevServicesProcessor.java | 7 +- extensions/devservices/oidc/pom.xml | 53 + .../oidc/OidcDevServicesConfig.java | 35 + .../oidc/OidcDevServicesConfigBuildItem.java} | 10 +- .../oidc/OidcDevServicesProcessor.java | 932 ++++++++++++++++++ extensions/devservices/pom.xml | 1 + extensions/oidc/deployment/pom.xml | 4 + .../devservices/AbstractDevUIProcessor.java | 42 + .../devservices/OidcDevUIProcessor.java | 29 +- .../keycloak/KeycloakDevUIProcessor.java | 28 +- .../LightweightDevServicesProcessor.java | 628 ------------ .../resources/dev-ui/qwc-oidc-provider.js | 207 ++-- .../runtime/devui/OidcDevJsonRpcService.java | 9 + .../runtime/devui/OidcDevLoginObserver.java | 70 ++ .../OidcDevSessionCookieReaderHandler.java | 23 +- .../devui/OidcDevSessionLoginHandler.java | 91 ++ .../devui/OidcDevSessionLogoutHandler.java | 5 +- .../oidc/runtime/devui/OidcDevUiRecorder.java | 13 +- integration-tests/oidc-dev-services/pom.xml | 99 ++ .../it/oidc/dev/services/SecuredResource.java | 59 ++ .../src/main/resources/application.properties | 3 + ...BearerAuthenticationOidcDevServicesIT.java | 8 + ...arerAuthenticationOidcDevServicesTest.java | 77 ++ .../services/CodeFlowOidcDevServicesTest.java | 127 +++ integration-tests/pom.xml | 1 + 31 files changed, 1868 insertions(+), 749 deletions(-) create mode 100644 docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png create mode 100644 docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png create mode 100644 extensions/devservices/oidc/pom.xml create mode 100644 extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java rename extensions/{oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java => devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java} (50%) create mode 100644 extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java delete mode 100644 extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLoginHandler.java create mode 100644 integration-tests/oidc-dev-services/pom.xml create mode 100644 integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java create mode 100644 integration-tests/oidc-dev-services/src/main/resources/application.properties create mode 100644 integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java create mode 100644 integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java create mode 100644 integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java diff --git a/.github/native-tests.json b/.github/native-tests.json index cd9b3beb494a9..ee6584b2b45f4 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -75,7 +75,7 @@ { "category": "Security2", "timeout": 75, - "test-modules": "oidc, oidc-code-flow, oidc-tenancy, oidc-client, oidc-client-reactive, oidc-token-propagation, oidc-wiremock, oidc-client-wiremock, oidc-wiremock-providers", + "test-modules": "oidc, oidc-code-flow, oidc-tenancy, oidc-client, oidc-client-reactive, oidc-token-propagation, oidc-wiremock, oidc-client-wiremock, oidc-wiremock-providers, oidc-dev-services", "os-name": "ubuntu-latest" }, { diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 5f4dcdca4494a..74b972f8d4a15 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1099,6 +1099,11 @@ quarkus-devservices-keycloak ${project.version} + + io.quarkus + quarkus-devservices-oidc + ${project.version} + io.quarkus quarkus-flyway diff --git a/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png b/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-for-custom-users.png new file mode 100644 index 0000000000000000000000000000000000000000..48ed2a6604f72b91adf7625976544ac75e5279e5 GIT binary patch literal 10688 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 OJ-DZJw;2BP<-Y(l6IrzY literal 0 HcmV?d00001 diff --git a/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png b/docs/src/main/asciidoc/images/dev-ui-oidc-dev-svc-login-page.png new file mode 100644 index 0000000000000000000000000000000000000000..7d923c72623e0f873773555a26aa79d13d5785ac GIT binary patch literal 7044 zcmeHMS5#B$wqA&0MYg!zAPBfsKtKegcNGNz37XI(v`q`W1qcK}KvV=NN|ml)2oM5p zTIi83gd!zGdXr8-T7r~tm-jy0JMQB-_dc94&U%;`S@|>9U*??OH@_9}7^2O2itiKv z0Gv7x??VCLNEiUHnVvWT-oXp}l?Q&XBk$=Lo;Y!0WXfO?{Cd&nfvFG7!@&pjm$yBD zaQAStmqgln+uOS%9X)*3*vaYuz|W&||E{5bI(;m_Jb_uaus*ROy+XLG{Qgk$KR})A00t@uXee)BU9z!Y2Q97P@K}rpGN7Vp5Rk3e0=^E zo8eJ5o;%jx1YV|urYK<_$>5rwwb5TsZ3fSB`ODXH1=Q{AsP;%Zde8G*2Y}1>CDg#F z+VTl=0C%|mZT^M!AkGpE+~SLF{hilN`kky9OX&!Jo!$1|-OK2%w=AYfs8QZYYv~pC zI$p`P9b%f*Ij^+vaw$i;?%vICBR22c=FLeC%l&+r88R|N(|gHgeWG|| zW@Xuzt5QGoZeMBCN!zk!jCX8!4WA10zTw{>YLN07AF zhdBv+cFau{v@RT}cU$&bya)i#Dn))pFs+$KT>Q54q8ud-2JM`CUteEeT|G>MlgeZj z_7vX9yY2SjC^l^^!*oVT3459X4{HSxOC2ygoMIO5WS&RZIjC=ZIrQ7wZfR~+-}!gnnq|xpw9D#&IsyJgT!EW2K^^yrs3d z8~*9Cq@>#clI-N<^h8%!6h9i=mf=@}*{dK{Si&LQEBqdnI>ofA9)`8Qwe=mb!~|I} zmB7IYH~7f#h={NV(Y#z)RW;}U*`u2-1Wd-|H1pW~EVYB%;?j1XPm4QdEW+Rn*&|?JfAgj773qgeSNBS zRpnjI(hHhR_cWUteU#~wh2l6!dY%yLlSM{Gn1+BS*L7SOdaQ;25+EJviQ3)27_Eg% zAdse?<$Vsa^^NC)|3=JA;}SP>$%JH=Dd$`B2*;a>x*|-?*e?;5A?Fd z(KJ~enj1n<5do|XXHs|ex3_#$F2#7wOuxJ##G}%^=)>6ZS@uO2)A-MwyX8-8iSr3) zI|@WOo1eD=iVF%0&v0=W=<9DvrK#HM8X69K&`j(gaBy&>rt+QV=Dw111>>@fK4^S! zdKQebC}s2Wu{YEt)vhV}e`zc!DQRte*_C!0*5Bt|wXISU`Fdn{*v7_oPU_2A`L_E_ z@h!Is7nAiag$eXp9Wyy$rDB9$is(`DT{67acr;=Y0HU-K{OuxibRu*#J;W4bxXrS*kuiDZ;8DG8ePp?n;@e+ z+DEGb1Y@%*TmzbkvfzNsG^*%j{%e)&RCjCWiQaPMc$`FM@VK5dxK95?rI-{aGCnqPv z?2nHJGYt;9DeF|W$$JZDwD3(g)mjvUXwAvy;8+YMW9xSyn}z>N0yZ*EJ@%(NYD9_~ANt;aAG zYUkm%?R7ggW+fye{wQu6`8Mzgm-oYQ`^h2W54~ydwy1uptL(*^YGnxtiO9%Pdn*iz zzc&B`B={?W0}hxPjw(opo>iMPdS_*2Wx(b}e4L2d&*m9-Qs54zzYjl#?5XaK>7LF| zF~4cDx>B{gy4Rp5b#T}eE}jS#6NAwXNPA$QN6LGYjL^@En*DLZ^j&HyV*qBbRb!sK zybk~y*VjF{MIJdiDsDHla<;|N_L9EQF%rgwkM#A`f_8pLt2oQ1^0U@Q>f}dY1_yoa zBng}@GiiT>tXAT%?HkTz^y_!`%6vlUa=s{b(Yq=EnnjytVuX=LO)gW?SG@hA|nLwFD1Re1Ib&+i;GiZ6tBuNhh3#q$(ByCe9n>=&fnKJI?=?&IDq!bo7 zn^y6yEiGcWLLxDYXIH9lW_mgi`t#@M%}t)OL$|J6*_e*z0xFMy;AI|2-#(_7bcv83 zFtcq^Q(ZkO$o#GYD>@t;K8KP`d8C`q3T2fR$)!f==3@f&$U7Ua|0MW7JOkKr3bSg? za5O#Je?h0aNvR&DkIbw~M#V&`Cu1@ocxZ>6YzBtd^dSM zwE^p|S*l)rS%2XM622!OhiFU)e5Nf{sMd41Z1MCTHwG4cx+J^mc^tTgD9oZ1Amr8y z?n4WNDGJnA%fx_0s4iY;%Cd2>Umu*o#T1hl;o*BpM^n2<<7jv=>k4TE zQ=Ok*j`P*^-njp|g-nUM`1Fw3+vecVQ*9>-CS0`7$t0aVnG@2-IFtmg)`q`qqgR;5 zL7~uR&z`ZfcU4uD7S}O$X;xS{Q3VA>#pYfbZ8S((!o|Vde3an^w>T(z^XARZ(;Eiq zik80LUqK6qU8(UZ2nS=oJ9j=Oh_$=W#(4z=*9X&z;Y6Ztep1rh%2Op7gu}A$ZeCvA z5OTw~xT>gbYp3BQm=u-0Jb$B#O0^g+@DdgdY3O7INIr_97iAR;oVBQ-_M zR?FDbbRm>It8RVR1rt-Rhh+eskn8KSAKEjlE~|xwe0}vU6G8w}T)JRAXJcbh=@tOQ zUH^YE>z?(2v@rvXO;^vypC-=%z=Z4nDr@LPwjw~eZWZq0@h(NK|I+UR!^69aNnQ5# zS$j0C2inEve>*P?7$P5gW{m|GGQQ1a>*Ea+GWI}UwkbCoZtI{q*g&F)Z&IIjcq&s0 zM!t=W&DAScm`O?Ua&jV~1{sO*flC7?3Dkmw$f&5@b^}%bc%GSRHH@{15$aG%e3}Zv z-O}FvMVEt0@>LKvd}B1ky+SKR1XC=F59zxT{&gTz31~^yV6AId)cFieHH9Za3C6`I zPuMUOpE$gAj8N|9qRK|7ITV& z5U7r_7cZs=2nevwNZ_1ZT+HA^r^S9*Gx{s-$`$+(fYxvZ z4RUgF#@taRAXcJCO;fL4#|o8T%*|{27grV+FRq_R_V)xnUkKrjb#)(CjnrM8<2kgDgSM&J*&STKjt-$Lb*;jl$+Vw*z!n;FO(0d|NP$b zpX=e(2CHEIQmy5hQ1D?#In9D>GwWPBG}<@1(Ou&qM95!v`%0y5clH|;npQcmp6$PX ze>sgv%FeF+^dr8zyBn!`1h9_k|K2V2CiCtc$NP^AL_NHY0Zmidv$G9I8vwZW z;*M7yagkwJ8Iw5KTkm92FBi#rLtuwI{B4_S2>42EZ!(3zUpKQ@M0G3{zmz6?x8 z{tvpe_$iB$6zZ^9x4bHK-ISY~+sq`eusV0QGbQu+^OH}Xf{9j2h!rd!a^s;H>AX;JOEo$7*R*31)PE-^e9Kbm3}ICr&KJx%oni0;nr9;@GF+|%0~ zS5}vb@^(t{JmdRkZl)$mjq(X~0aJCQ&bgO;^ZCp5gW z?R%%6MI<$E_Z(Db*P7$0L`x(cJfX1TT(TMNP+fZmOJ5qZeqwyk(y+39Y9UQFK$q3{ zKnw=ULT@A=931H8hlYlJ`ucTbtS0@Z`{T8iJpoZ{z=S_Y8zYjg z#kL`zWR5u?5KJc5zTZKqI|(jUPAdDDpMOhcrr`bLWQDwog#{5>fGfvkXOE&xg6PBi z0%zM~$iXEv2beB>ghVRMv2E!vLT=8jiI0Cs*g)5Ld94@M?q_=>!|;&qFCE=oT?b8T zRzIg-hw0{@7mexZ?11QH`2`DSI_gA4#)yw@={)HbnTU%U1Jj|WdS_=RHFfFLt5+8! zj0J8;?9B)rHjygI?0Vct6H18gQHvH8et!N0D7!mJ_}1b~ti~uP8N9%Xn1e%@ZF?U34&R~V`(}OSiuvdpYC`v_Jowwq4$sBzCJbG{Kfv< zz2wTV)sBu+6Kyw>mpfAgi)rvDEF9JiVS;$*xbTy6pkP)q!h)D-%L${rxg_+W244 z%o$k}+iR!XkDP1lc2e9W3w)dLS{o`2>;VJ0R7p-!4cpMz^7^!@AX8f?c=DIHKzAdC zSxKA)6uDGI1-s{utHX39t^V$?mJP0?70D29YJc>dkW9&+m$NLzOusmWHs=lJY|IQB6WN`DJA71_XaJgrz@>ELHuy(*i)V&JYG~;+>A+mV@AA* zFfPKVK^qG?B5V#Np`Ju zJ5^!ahfOYte%!C_H$e?UbM;=37?aJljFl*zeWcVtjw{+Q!0-exewaY1c55QvVh?$W|S9#IVx zY1E8iGQmOjR#%2(N=lBfcL@hFY7ZD`EM}B>S>BxhBd_(dhk2*}_|AXkrGQGs&*aXo zG@eVp*ZM9em1dfOb$i_Uow}NB!?PC=5lMA{dg9_8^PTO40%h-w?9>M90@o%J8Sdm|$OHqaJ1Wg`{AyS-T&hE{dyd&}pK zRF?dx?@tc@Z&3=ZOzeY!EkJ8*2PNzuRDAeygc)d6md4W)z>YG}czF+wtQ=@{j0Pp} zR-p7(*M+{w-g$5USrlu1e#^`TVO0#lb4edZ+MfY59m|ISbT@0;6M`*^{lfOM{x(g`5s kn(mo@oB!iI_;`2}1Fz-X+OnDhUrqr!4i_@% literal 0 HcmV?d00001 diff --git a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc index dcdbdbeac0415..2cc1425252e8b 100644 --- a/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-dev-services.adoc @@ -21,11 +21,12 @@ The Dev Services for Keycloak feature starts a Keycloak container for both the d It initializes them by registering the existing Keycloak realm or creating a new realm with the client and users required for you to start developing your Quarkus application secured by Keycloak immediately. The container restarts when the `application.properties` or the realm file changes have been detected. -Additionally, xref:dev-ui.adoc[Dev UI] available at http://localhost:8080/q/dev[/q/dev] complements this feature with a Dev UI page, which helps to acquire the tokens from Keycloak and test your Quarkus application. +Additionally, xref:dev-ui.adoc[Dev UI] available at http://localhost:8080/q/dev-ui/extensions[/q/dev-ui/extensions] complements this feature with a Dev UI page, which helps to acquire the tokens from Keycloak and test your Quarkus application. If `quarkus.oidc.auth-server-url` is already set, then a generic OpenID Connect Dev Console, which can be used with all OpenID Connect providers, is activated. For more information, see <>. +[[dev-services-for-keycloak]] == Dev Services for Keycloak Start your application without configuring `quarkus.oidc` properties in the `application.properties` file: @@ -406,6 +407,46 @@ This document refers to the `http://localhost:8080/q/dev-ui` Dev UI URL in sever If you customize `quarkus.http.root-path` or `quarkus.http.non-application-root-path` properties, then replace `q` accordingly. For more information, see the https://quarkus.io/blog/path-resolution-in-quarkus/[Path resolution in Quarkus] blog post. +== Dev Services for OIDC + +When you work with Keycloak in production, <> provides the best dev mode experience. +For other OpenID Connect providers, it is recommended to enable the Dev Services for OIDC like in the example below: + +[source,properties] +---- +quarkus.oidc.devservices.enabled=true +---- + +NOTE: the Dev Services for OIDC are enabled by default if Docker and Podman are not available. + +Once enabled, Quarkus starts a new OIDC server that supports most common OpenID Connect operations. +You can confirm in the Dev UI console that the OIDC server started, you will see output similar to the following: + +[source,shell] +---- +2025-01-08 20:50:20,900 INFO [io.qua.dev.oid.OidcDevServicesProcessor] (build-16) Dev Services for OIDC started on http://localhost:38139 +---- + +If you navigate to the <>, you can log into the OIDC server as builtin users `alice` or `bob`: + +image::dev-ui-oidc-dev-svc-login-page.png[alt=Dev Services for OIDC builtin user login,role="center"] + +As always, default `alice` roles are `admin` and `user`, while default `bob` role is `user`. +Nevertheless, you can configure roles according to your preference: + +[source,properties] +---- +quarkus.oidc.devservices.roles.alice=root <1> +quarkus.oidc.devservices.roles.bob=guest +---- +<1> Assign a `root` role to the user `alice`. + +Another option is log in as a custom user with username and roles of your choice: + +image::dev-ui-oidc-dev-svc-login-for-custom-users.png[alt=Dev Services for OIDC custom user login,role="center"] + +Whichever user you choose, password is not required. + == References * xref:dev-ui.adoc[Dev UI] diff --git a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java index 20857559b3c47..9a0087eea9e26 100644 --- a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java +++ b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesConfig.java @@ -28,12 +28,6 @@ public interface KeycloakDevServicesConfig { @WithDefault("true") boolean enabled(); - /** - * Use lightweight dev services instead of Keycloak - */ - @ConfigItem(defaultValue = "false") - public boolean lightweight; - /** * The container image name for Dev Services providers. * diff --git a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java index 6227b263b3125..8709a2a76b2c2 100644 --- a/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java +++ b/extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java @@ -145,7 +145,8 @@ DevServicesResultBuildItem startKeycloakContainer( DevServicesConfig devServicesConfig, DockerStatusBuildItem dockerStatusBuildItem) { if (devSvcRequiredMarkerItems.isEmpty() - || linuxContainersNotAvailable(dockerStatusBuildItem, devSvcRequiredMarkerItems)) { + || linuxContainersNotAvailable(dockerStatusBuildItem, devSvcRequiredMarkerItems) + || oidcDevServicesEnabled()) { if (devService != null) { closeDevService(); } @@ -248,6 +249,10 @@ public void run() { return devService.toBuildItem(); } + private static boolean oidcDevServicesEnabled() { + return ConfigProvider.getConfig().getOptionalValue("quarkus.oidc.devservices.enabled", boolean.class).orElse(false); + } + private static boolean linuxContainersNotAvailable(DockerStatusBuildItem dockerStatusBuildItem, List devSvcRequiredMarkerItems) { if (dockerStatusBuildItem.isContainerRuntimeAvailable()) { diff --git a/extensions/devservices/oidc/pom.xml b/extensions/devservices/oidc/pom.xml new file mode 100644 index 0000000000000..ec198748674a6 --- /dev/null +++ b/extensions/devservices/oidc/pom.xml @@ -0,0 +1,53 @@ + + + + quarkus-devservices-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-devservices-oidc + Quarkus - DevServices - OIDC + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-devservices-common + + + io.smallrye.reactive + smallrye-mutiny-vertx-web + + + io.smallrye + smallrye-jwt-build + + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + diff --git a/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java new file mode 100644 index 0000000000000..e97eef86dad8d --- /dev/null +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfig.java @@ -0,0 +1,35 @@ +package io.quarkus.devservices.oidc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigDocDefault; +import io.quarkus.runtime.annotations.ConfigDocMapKey; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; + +/** + * OpenID Connect Dev Services configuration. + */ +@ConfigRoot +@ConfigMapping(prefix = "quarkus.oidc.devservices") +public interface OidcDevServicesConfig { + + /** + * Use OpenID Connect Dev Services instead of Keycloak. + */ + @ConfigDocDefault("Enabled when Docker and Podman are not available") + Optional enabled(); + + /** + * A map of roles for OIDC identity provider users. + *

+ * If empty, default roles are assigned: `alice` receives `admin` and `user` roles, while other users receive + * `user` role. + * This map is used for role creation when no realm file is found at the `realm-path`. + */ + @ConfigDocMapKey("role-name") + Map> roles(); + +} diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java similarity index 50% rename from extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java rename to extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java index 30d9fac042b9b..14fc63be89258 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesConfigBuildItem.java +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesConfigBuildItem.java @@ -1,18 +1,22 @@ -package io.quarkus.oidc.deployment.devservices.keycloak; +package io.quarkus.devservices.oidc; import java.util.Map; import io.quarkus.builder.item.SimpleBuildItem; -public final class LightweightDevServicesConfigBuildItem extends SimpleBuildItem { +/** + * OIDC Dev Services configuration properties. + */ +public final class OidcDevServicesConfigBuildItem extends SimpleBuildItem { private final Map config; - public LightweightDevServicesConfigBuildItem(Map config) { + OidcDevServicesConfigBuildItem(Map config) { this.config = config; } public Map getConfig() { return config; } + } diff --git a/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java new file mode 100644 index 0000000000000..1729e3ad64e70 --- /dev/null +++ b/extensions/devservices/oidc/src/main/java/io/quarkus/devservices/oidc/OidcDevServicesProcessor.java @@ -0,0 +1,932 @@ +package io.quarkus.devservices.oidc; + +import static io.quarkus.deployment.bean.JavaBeanUtil.capitalize; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.jwt.Claims; +import org.jboss.logging.Logger; +import org.jose4j.base64url.Base64Url; + +import io.quarkus.deployment.IsNormal; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem.RunningDevService; +import io.quarkus.deployment.builditem.DockerStatusBuildItem; +import io.quarkus.deployment.dev.devservices.DevServicesConfig; +import io.quarkus.runtime.configuration.ConfigUtils; +import io.smallrye.jwt.build.Jwt; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.core.http.HttpServer; +import io.vertx.mutiny.ext.web.Router; +import io.vertx.mutiny.ext.web.RoutingContext; +import io.vertx.mutiny.ext.web.handler.BodyHandler; + +@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = DevServicesConfig.Enabled.class) +public class OidcDevServicesProcessor { + + private static final Logger LOG = Logger.getLogger(OidcDevServicesProcessor.class); + private static final String CONFIG_PREFIX = "quarkus.oidc."; + private static final String OIDC_ENABLED = CONFIG_PREFIX + "enabled"; + private static final String TENANT_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "tenant-enabled"; + private static final String AUTH_SERVER_URL_CONFIG_KEY = CONFIG_PREFIX + "auth-server-url"; + private static final String PROVIDER_CONFIG_KEY = CONFIG_PREFIX + "provider"; + private static final String APPLICATION_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; + private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; + private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; + + private static volatile KeyPair kp; + private static volatile String kid; + private static volatile String baseURI; + private static volatile String clientId; + private static volatile String clientSecret; + private static volatile String applicationType; + private static volatile Map configProperties; + private static volatile Map> userToDefaultRoles; + private static volatile Runnable closeDevServiceTask; + + @BuildStep + DevServicesResultBuildItem startServer(CuratedApplicationShutdownBuildItem closeBuildItem, + OidcDevServicesConfig devServicesConfig, DockerStatusBuildItem dockerStatusBuildItem, + BuildProducer devServiceConfigProducer) { + if (shouldNotStartServer(devServicesConfig, dockerStatusBuildItem)) { + closeDevSvcIfNecessary(); + return null; + } + + userToDefaultRoles = devServicesConfig.roles(); + if (closeDevServiceTask == null) { + LOG.info("Starting Dev Services for OIDC"); + Vertx vertx = Vertx.vertx(); + HttpServerOptions options = new HttpServerOptions(); + options.setPort(0); + HttpServer httpServer = vertx.createHttpServer(options); + + Router router = Router.router(vertx); + httpServer.requestHandler(router); + registerRoutes(router); + + httpServer.listenAndAwait(); + baseURI = "http://localhost:" + httpServer.actualPort(); + closeDevServiceTask = new Runnable() { + + private volatile boolean closed = false; + + @Override + public void run() { + if (closed) { + return; + } + closed = true; + // this is done on delegates because closing Mutiny wrapper can result in unrelated exception + // when other tests (not necessarily using this dev services) run after a test using this service + httpServer.getDelegate().close(httpServerResult -> { + if (httpServerResult != null && httpServerResult.failed()) { + LOG.error("Failed to close HTTP Server", httpServerResult.cause()); + } + vertx.getDelegate().close(vertxResult -> { + if (vertxResult != null && vertxResult.failed()) { + LOG.error("Failed to close Vertx instance", vertxResult.cause()); + } + }); + }); + } + }; + closeBuildItem.addCloseTask(OidcDevServicesProcessor::closeDevSvcIfNecessary, true); + updateDevSvcConfigProperties(); + LOG.infof("Dev Services for OIDC started on %s", baseURI); + } else if (!getOidcClientId().equals(clientId) || !getOidcApplicationType().equals(applicationType)) { + updateDevSvcConfigProperties(); + } + + devServiceConfigProducer.produce(new OidcDevServicesConfigBuildItem(configProperties)); + return new RunningDevService("oidc-dev-services", null, () -> { + }, configProperties).toBuildItem(); + } + + private static void closeDevSvcIfNecessary() { + if (closeDevServiceTask != null) { + closeDevServiceTask.run(); + closeDevServiceTask = null; + } + } + + private static boolean shouldNotStartServer(OidcDevServicesConfig devServicesConfig, + DockerStatusBuildItem dockerStatusBuildItem) { + boolean explicitlyDisabled = devServicesConfig.enabled().isPresent() && !devServicesConfig.enabled().get(); + if (explicitlyDisabled) { + LOG.debug("Not starting Dev Services for OIDC as it has been disabled in the config"); + return true; + } + if (devServicesConfig.enabled().isEmpty() && dockerStatusBuildItem.isContainerRuntimeAvailable()) { + LOG.debug("Not starting Dev Services for OIDC as detected support the container functionality"); + return true; + } + if (!isOidcEnabled()) { + LOG.debug("Not starting Dev Services for OIDC as OIDC extension has been disabled in the config"); + return true; + } + if (!isOidcTenantEnabled()) { + LOG.debug("Not starting Dev Services for OIDC as 'quarkus.oidc.tenant.enabled' is false"); + return true; + } + if (ConfigUtils.isPropertyPresent(AUTH_SERVER_URL_CONFIG_KEY)) { + LOG.debug("Not starting Dev Services for OIDC as 'quarkus.oidc.auth-server-url' has been provided"); + return true; + } + if (ConfigUtils.isPropertyPresent(PROVIDER_CONFIG_KEY)) { + LOG.debug("Not starting Dev Services for OIDC as 'quarkus.oidc.provider' has been provided"); + return true; + } + return false; + } + + private static void updateDevSvcConfigProperties() { + // relevant configuration has changed + clientId = getOidcClientId(); + clientSecret = getOidcClientSecret(); + applicationType = getOidcApplicationType(); + final Map aConfigProperties = new HashMap<>(); + aConfigProperties.put(AUTH_SERVER_URL_CONFIG_KEY, baseURI); + aConfigProperties.put(APPLICATION_TYPE_CONFIG_KEY, applicationType); + aConfigProperties.put(CLIENT_ID_CONFIG_KEY, clientId); + aConfigProperties.put(CLIENT_SECRET_CONFIG_KEY, clientSecret); + configProperties = Map.copyOf(aConfigProperties); + } + + private static void registerRoutes(Router router) { + BodyHandler bodyHandler = BodyHandler.create(); + router.get("/").handler(OidcDevServicesProcessor::mainRoute); + router.get("/.well-known/openid-configuration").handler(OidcDevServicesProcessor::configuration); + router.get("/authorize").handler(OidcDevServicesProcessor::authorize); + router.post("/login").handler(bodyHandler).handler(OidcDevServicesProcessor::login); + router.post("/token").handler(bodyHandler).handler(OidcDevServicesProcessor::token); + router.get("/keys").handler(OidcDevServicesProcessor::getKeys); + router.get("/logout").handler(OidcDevServicesProcessor::logout); + router.get("/userinfo").handler(OidcDevServicesProcessor::userInfo); + + // can be used for testing of bearer token authentication + router.get("/testing/generate/access-token").handler(OidcDevServicesProcessor::generateAccessToken); + + KeyPairGenerator kpg; + try { + kpg = KeyPairGenerator.getInstance("RSA"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + kpg.initialize(2048); + kp = kpg.generateKeyPair(); + kid = createKeyId(); + } + + private static void generateAccessToken(RoutingContext rc) { + String user = rc.request().getParam("user"); + if (user == null || user.isEmpty()) { + rc.response().setStatusCode(400).endAndForget("Missing required parameter: user"); + return; + } + String rolesParam = rc.request().getParam("roles"); + Set roles = new HashSet<>(); + if (rolesParam == null || rolesParam.isEmpty()) { + roles.addAll(getUserRoles(user)); + } else { + roles.addAll(Arrays.asList(rolesParam.split(","))); + } + rc.response().endAndForget(createAccessToken(user, roles, Set.of("openid", "email"))); + } + + private static List getUsers() { + if (userToDefaultRoles.isEmpty()) { + return Arrays.asList("alice", "bob"); + } else { + List ret = new ArrayList<>(userToDefaultRoles.keySet()); + Collections.sort(ret); + return ret; + } + } + + private static List getUserRoles(String user) { + List roles = userToDefaultRoles.get(user); + return roles == null ? ("alice".equals(user) ? List.of("admin", "user") : List.of("user")) + : roles; + } + + private static boolean isOidcEnabled() { + return ConfigProvider.getConfig().getValue(OIDC_ENABLED, Boolean.class); + } + + private static boolean isOidcTenantEnabled() { + return ConfigProvider.getConfig().getOptionalValue(TENANT_ENABLED_CONFIG_KEY, Boolean.class).orElse(true); + } + + private static String getOidcApplicationType() { + return ConfigProvider.getConfig().getOptionalValue(APPLICATION_TYPE_CONFIG_KEY, String.class).orElse("service"); + } + + private static String getOidcClientId() { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_ID_CONFIG_KEY, String.class) + .orElse("quarkus-app"); + } + + private static String getOidcClientSecret() { + return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class) + .orElseGet(() -> UUID.randomUUID().toString()); + } + + private static void mainRoute(RoutingContext rc) { + rc.response().endAndForget("OIDC server up and running"); + } + + private static void configuration(RoutingContext rc) { + String data = """ + { + "token_endpoint":"%1$s/token", + "token_endpoint_auth_methods_supported":[ + "client_secret_post", + "private_key_jwt", + "client_secret_basic" + ], + "jwks_uri":"%1$s/keys", + "response_modes_supported":[ + "query" + ], + "subject_types_supported":[ + "pairwise" + ], + "id_token_signing_alg_values_supported":[ + "RS256" + ], + "response_types_supported":[ + "code", + "id_token", + "code id_token", + "id_token token", + "code id_token token" + ], + "scopes_supported":[ + "openid", + "profile", + "email", + "offline_access" + ], + "issuer":"%1$s", + "request_uri_parameter_supported":false, + "userinfo_endpoint":"%1$s/userinfo", + "authorization_endpoint":"%1$s/authorize", + "device_authorization_endpoint":"%1$s/devicecode", + "http_logout_supported":true, + "frontchannel_logout_supported":true, + "end_session_endpoint":"%1$s/logout", + "claims_supported":[ + "sub", + "iss", + "aud", + "exp", + "iat", + "auth_time", + "acr", + "nonce", + "preferred_username", + "name", + "tid", + "ver", + "at_hash", + "c_hash", + "email" + ] + } + """.formatted(baseURI); + rc.response().putHeader("Content-Type", "application/json"); + rc.endAndForget(data); + } + + /* + * First request: + * GET + * https://localhost:X/authorize?response_type=code&client_id=SECRET&scope=openid+openid+ + * email+profile&redirect_uri=http://localhost:8080/Login/oidcLoginSuccess&state=STATE + * + * returns a 302 to + * GET http://localhost:8080/Login/oidcLoginSuccess?code=CODE&state=STATE + */ + private static void authorize(RoutingContext rc) { + String response_type = rc.request().params().get("response_type"); + String clientId = rc.request().params().get("client_id"); + String scope = rc.request().params().get("scope"); + String state = rc.request().params().get("state"); + String redirect_uri = rc.request().params().get("redirect_uri"); + URI redirect; + try { + redirect = new URI(redirect_uri + "?state=" + state); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + StringBuilder predefinedUsers = new StringBuilder(); + for (String predefinedUser : getUsers()) { + predefinedUsers.append(" \n"); + } + rc.response() + .endAndForget( + """ + + + Login + + + +

+
+
Login
+
+
+
+ """ + + """ + + + + + %2$s +
+
+ Custom user +
+ + + + +
+
+ +
+
+
+
+ + + """.formatted(redirect.toASCIIString(), predefinedUsers, response_type, clientId, + scope)); + } + + private static void login(RoutingContext rc) { + String redirect_uri = rc.request().params().get("redirect_uri"); + String predefined = null; + for (Map.Entry param : rc.request().params()) { + if (param.getKey().startsWith("predefined")) { + predefined = param.getValue(); + break; + } + } + String name = rc.request().params().get("name"); + String roles = rc.request().params().get("roles"); + String scope = rc.request().params().get("scope"); + String clientId = rc.request().params().get("client_id"); + String responseType = rc.request().params().get("response_type"); + + if (predefined != null) { + name = predefined; + roles = String.join(",", getUserRoles(name)); + } + if (name == null || name.isBlank()) { + name = "user"; + } + + if (responseType == null || responseType.isEmpty()) { + rc.response().setStatusCode(500).endAndForget("Illegal state - the 'response_type' parameter is required"); + return; + } + + StringBuilder queryParams = new StringBuilder(); + + if (responseType.contains("code")) { + String code = new UserAndRoles(name, roles).encode(); + queryParams.append("&code=").append(code); + } + + if (responseType.contains("idtoken")) { + String idToken = createIdToken(name, getUserRolesSet(roles), clientId); + queryParams.append("&id_token=").append(idToken); + } + + if (responseType.contains(" token")) { + String accessToken = createAccessToken(name, getUserRolesSet(roles), getScopeAsSet(scope)); + queryParams.append("&access_token=").append(accessToken); + } + + rc.response() + .putHeader("Location", redirect_uri + queryParams) + .setStatusCode(302) + .endAndForget(); + } + + private static void token(RoutingContext rc) { + String grantType = rc.request().formAttributes().get("grant_type"); + switch (grantType) { + case "authorization_code" -> authorizationCodeFlowTokenEndpoint(rc); + case "refresh_token" -> refreshTokenEndpoint(rc); + case "client_credentials" -> clientCredentialsTokenEndpoint(rc); + case "password" -> passwordTokenEndpoint(rc); + default -> rc.response() + .setStatusCode(400) + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget("Unsupported grant type: " + grantType); + } + } + + private static void passwordTokenEndpoint(RoutingContext rc) { + String scope = rc.request().formAttributes().get("scope"); + String clientId = rc.request().formAttributes().get("client_id"); + String username = rc.request().formAttributes().get("username"); + if (clientId == null || clientId.isEmpty()) { + LOG.warn("Invalid client ID, denying token request"); + invalidTokenResponse(rc); + return; + } + if (username == null || username.isEmpty()) { + LOG.warn("Invalid username, denying token request"); + invalidTokenResponse(rc); + return; + } + List userRoles = getUserRoles(username); + String accessToken = createAccessToken(username, new HashSet<>(userRoles), getScopeAsSet(scope)); + String refreshToken = new UserAndRoles(username, String.join(",", userRoles)).encode(); + String data = """ + { + "access_token":"%s", + "token_type":"Bearer", + "expires_in":3600, + "refresh_token":"%s" + } + """.formatted(accessToken, refreshToken); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + private static void clientCredentialsTokenEndpoint(RoutingContext rc) { + String scope = rc.request().formAttributes().get("scope"); + String clientId = rc.request().formAttributes().get("client_id"); + if (clientId == null || clientId.isEmpty()) { + LOG.warn("Invalid client ID, denying token request"); + invalidTokenResponse(rc); + return; + } + String accessToken = createAccessToken(clientId, new HashSet<>(getUserRoles(clientId)), getScopeAsSet(scope)); + String data = """ + { + "access_token": "%s", + "token_type": "Bearer", + "expires_in": 3600 + } + """.formatted(accessToken); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + private static void refreshTokenEndpoint(RoutingContext rc) { + String clientId = rc.request().formAttributes().get("client_id"); + String clientSecret = rc.request().formAttributes().get("client_secret"); + String scope = rc.request().formAttributes().get("scope"); + if (clientId == null || clientId.isEmpty()) { + LOG.warn("Invalid client ID, denying token refresh"); + invalidTokenResponse(rc); + return; + } + if (clientSecret == null || clientSecret.isEmpty()) { + LOG.warn("Invalid client secret, denying token refresh"); + invalidTokenResponse(rc); + return; + } + String refreshToken = rc.request().formAttributes().get("refresh_token"); + UserAndRoles userAndRoles = decode(refreshToken); + if (userAndRoles == null) { + LOG.warn("Received invalid refresh token, denying token refresh"); + invalidTokenResponse(rc); + return; + } + + String accessToken = createAccessToken(userAndRoles.user, userAndRoles.getRolesAsSet(), getScopeAsSet(scope)); + String data = """ + { + "access_token": "%s", + "token_type": "Bearer", + "refresh_token": "%s", + "expires_in": 3600 + } + """.formatted(accessToken, refreshToken); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + /* + * OIDC calls POST /token? + * grant_type=authorization_code + * &code=CODE + * &redirect_uri=URI + * + * returns: + * + * { + * "token_type":"Bearer", + * "scope":"openid email profile", + * "expires_in":3600, + * "ext_expires_in":3600, + * "access_token":TOKEN, + * "id_token":JWT + * } + * + * ID token: + * { + * "ver": "2.0", + * "iss": "http://localhost", + * "sub": "USERID", + * "aud": "CLIENTID", + * "exp": 1641906214, + * "iat": 1641819514, + * "nbf": 1641819514, + * "name": "Foo Bar", + * "preferred_username": "user@example.com", + * "oid": "OPAQUE", + * "email": "user@example.com", + * "tid": "TENANTID", + * "aio": "AZURE_OPAQUE" + * } + */ + private static void authorizationCodeFlowTokenEndpoint(RoutingContext rc) { + // TODO: check redirect_uri is same as in the initial Authorization Request + String clientId = rc.request().formAttributes().get("client_id"); + if (clientId == null || clientId.isEmpty()) { + clientId = OidcDevServicesProcessor.clientId; + } + String scope = rc.request().formAttributes().get("scope"); + + String code = rc.request().formAttributes().get("code"); + UserAndRoles userAndRoles = decode(code); + if (userAndRoles == null) { + invalidTokenResponse(rc); + return; + } + + String accessToken = createAccessToken(userAndRoles.user, userAndRoles.getRolesAsSet(), getScopeAsSet(scope)); + String idToken = createIdToken(userAndRoles.user, userAndRoles.getRolesAsSet(), clientId); + + String data = """ + { + "token_type":"Bearer", + "scope":"openid email profile", + "expires_in":3600, + "ext_expires_in":3600, + "access_token":"%s", + "id_token":"%s", + "refresh_token": "%s" + } + """.formatted(accessToken, idToken, userAndRoles.encode()); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(data); + } + + private static void invalidTokenResponse(RoutingContext rc) { + rc.response() + .setStatusCode(400) + .putHeader("Content-Type", "application/json") + .putHeader("Cache-Control", "no-store") + .endAndForget(""" + { + "error": "invalid_request" + } + """); + } + + private static String createIdToken(String user, Set roles, String clientId) { + return Jwt.claims() + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .issuer(baseURI) + .audience(clientId) + .subject(user) + .upn(user) + .claim("name", capitalize(user)) + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId(kid) + .sign(kp.getPrivate()); + } + + private static String createAccessToken(String user, Set roles, Set scope) { + return Jwt.claims() + .expiresIn(Duration.ofDays(1)) + .issuedAt(Instant.now()) + .issuer(baseURI) + .subject(user) + .scope(scope) + .upn(user) + .claim("name", capitalize(user)) + .claim(Claims.preferred_username, user + "@example.com") + .claim(Claims.email, user + "@example.com") + .groups(roles) + .jws() + .keyId(kid) + .sign(kp.getPrivate()); + } + + /* + * {"kty":"RSA", + * "use":"sig", + * "kid":"KEYID", + * "x5t":"KEYID", + * "n": + * "", + * "e":"", + * "x5c":[ + * "KEYID" + * ], + * "issuer":"http://localhost:port"}, + */ + private static void getKeys(RoutingContext rc) { + RSAPublicKey pub = (RSAPublicKey) kp.getPublic(); + String modulus = Base64.getUrlEncoder().encodeToString(pub.getModulus().toByteArray()); + String exponent = Base64.getUrlEncoder().encodeToString(pub.getPublicExponent().toByteArray()); + String data = """ + { + "keys": [ + { + "alg": "RS256", + "kty": "RSA", + "n": "%s", + "use": "sig", + "kid": "%s", + "issuer": "%s", + "e": "%s" + } + ] + } + """.formatted(modulus, kid, baseURI, exponent); + rc.response() + .putHeader("Content-Type", "application/json") + .endAndForget(data); + } + + /* + * /logout + * ?post_logout_redirect_uri=URI + * &id_token_hint=SECRET + */ + private static void logout(RoutingContext rc) { + // we have no cookie state + String redirect_uri = rc.request().params().get("post_logout_redirect_uri"); + rc.response() + .putHeader("Location", redirect_uri) + .setStatusCode(302) + .endAndForget(); + } + + private static void userInfo(RoutingContext rc) { + var authorization = rc.request().getHeader("Authorization"); + if (authorization != null && authorization.startsWith("Bearer ")) { + String token = authorization.substring("Bearer ".length()); + JsonObject claims = decodeJwtContent(token); + if (claims != null && claims.containsKey(Claims.preferred_username.name())) { + String data = """ + { + "preferred_username": "%1$s", + "sub": "%2$s", + "name": "%2$s", + "family_name": "%2$s", + "given_name": "%2$s", + "email": "%3$s" + } + """.formatted(claims.getString(Claims.preferred_username.name()), + claims.getString(Claims.sub.name()), claims.getString(Claims.email.name())); + rc.response() + .putHeader("Content-Type", "application/json") + .endAndForget(data); + return; + } + } + rc.response().setStatusCode(401).endAndForget("WWW-Authenticate: Bearer error=\"invalid_token\""); + } + + private static UserAndRoles decode(String encodedContent) { + if (encodedContent != null && !encodedContent.isEmpty()) { + String decodedCode = new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8); + int separator = decodedCode.indexOf('|'); + if (separator != -1) { + String user = decodedCode.substring(0, separator); + String roles = decodedCode.substring(separator + 1); + if (roles.isBlank()) { + roles = String.join(",", getUserRoles(user)); + } + return new UserAndRoles(user, roles); + } else if (getUsers().contains(decodedCode)) { + String roles = String.join(",", getUserRoles(decodedCode)); + return new UserAndRoles(decodedCode, roles); + } + } + return null; + } + + private static JsonObject decodeJwtContent(String jwt) { + String encodedContent = getJwtContentPart(jwt); + if (encodedContent == null) { + return null; + } + return decodeAsJsonObject(encodedContent); + } + + private static String getJwtContentPart(String jwt) { + StringTokenizer tokens = new StringTokenizer(jwt, "."); + // part 1: skip the token headers + tokens.nextToken(); + if (!tokens.hasMoreTokens()) { + return null; + } + // part 2: token content + String encodedContent = tokens.nextToken(); + + // let's check only 1 more signature part is available + if (tokens.countTokens() != 1) { + return null; + } + return encodedContent; + } + + private static String base64UrlDecode(String encodedContent) { + return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8); + } + + private static JsonObject decodeAsJsonObject(String encodedContent) { + try { + return new JsonObject(base64UrlDecode(encodedContent)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static Set getUserRolesSet(String roles) { + if (roles == null || roles.isEmpty()) { + return Set.of(); + } + return Arrays.stream(roles.split(",")).map(String::trim).collect(Collectors.toSet()); + } + + private static Set getScopeAsSet(String scope) { + if (scope == null || scope.isEmpty()) { + return Set.of(); + } + return Arrays.stream(scope.split(" ")).collect(Collectors.toSet()); + } + + private record UserAndRoles(String user, String roles) { + + private String encode() { + // store user|roles in the code param as Base64 + return Base64.getUrlEncoder().encodeToString((user + "|" + roles).getBytes(StandardCharsets.UTF_8)); + } + + private Set getRolesAsSet() { + if (roles == null || roles.isEmpty()) { + return Set.of(); + } else { + return new HashSet<>(Arrays.asList(roles.split("[,\\s]+"))); + } + } + + } + + private static String createKeyId() { + try { + return Base64Url.encode(MessageDigest.getInstance("SHA-256").digest(kp.getPrivate().getEncoded())); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to generate key id", e); + } + } +} diff --git a/extensions/devservices/pom.xml b/extensions/devservices/pom.xml index 5f0851f718f7a..fc3e4fae0d123 100644 --- a/extensions/devservices/pom.xml +++ b/extensions/devservices/pom.xml @@ -29,6 +29,7 @@ common deployment keycloak + oidc diff --git a/extensions/oidc/deployment/pom.xml b/extensions/oidc/deployment/pom.xml index 8e8796c16474f..8f61153678894 100644 --- a/extensions/oidc/deployment/pom.xml +++ b/extensions/oidc/deployment/pom.xml @@ -54,6 +54,10 @@ io.quarkus quarkus-devservices-keycloak
+ + io.quarkus + quarkus-devservices-oidc + io.quarkus diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java index 1385aa5929874..5378b6a02f419 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/AbstractDevUIProcessor.java @@ -3,22 +3,29 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Optional; + +import org.eclipse.microprofile.config.ConfigProvider; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; +import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.builditem.ConfigurationBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.devui.spi.page.Page; +import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; import io.quarkus.oidc.runtime.devui.OidcDevUiRpcSvcPropertiesBean; import io.quarkus.runtime.RuntimeValue; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HttpConfiguration; public abstract class AbstractDevUIProcessor { protected static final String CONFIG_PREFIX = "quarkus.oidc."; protected static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; + private static final String APP_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; protected static CardPageBuildItem createProviderWebComponent(OidcDevUiRecorder recorder, Capabilities capabilities, @@ -111,4 +118,39 @@ private static String getProperty(ConfigurationBuildItem configurationBuildItem, return propertyValue; } + + protected static String getApplicationType() { + return getApplicationType(null); + } + + protected static String getApplicationType(OidcTenantConfig providerConfig) { + Optional appType = ConfigProvider.getConfig() + .getOptionalValue(APP_TYPE_CONFIG_KEY, + io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType.class); + if (appType.isEmpty() && providerConfig != null) { + appType = providerConfig.applicationType(); + } + return appType + // constant is "WEB_APP" while documented value is "web-app" and we expect users to use "web-app" + // if this get changed, we need to update qwc-oidc-provider.js as well + .map(at -> io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType.WEB_APP == at ? "web-app" + : at.name().toLowerCase()) + .orElse(OidcTenantConfig.ApplicationType.SERVICE.name().toLowerCase()); + } + + protected static void registerOidcWebAppRoutes(BuildProducer routeProducer, OidcDevUiRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "readSessionCookie") + .handler(recorder.readSessionCookieHandler()) + .build()); + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "logout") + .handler(recorder.logoutHandler()) + .build()); + routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() + .nestedRoute("io.quarkus.quarkus-oidc", "login") + .handler(recorder.loginHandler()) + .build()); + } } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java index def279cb26ad3..7223bdc5e018f 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/OidcDevUIProcessor.java @@ -17,14 +17,13 @@ import io.quarkus.deployment.builditem.ConfigurationBuildItem; import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem; +import io.quarkus.devservices.oidc.OidcDevServicesConfigBuildItem; import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.oidc.OidcTenantConfig; -import io.quarkus.oidc.OidcTenantConfig.ApplicationType; import io.quarkus.oidc.OidcTenantConfig.Provider; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.deployment.OidcBuildTimeConfig; -import io.quarkus.oidc.deployment.devservices.keycloak.LightweightDevServicesConfigBuildItem; import io.quarkus.oidc.runtime.devui.OidcDevJsonRpcService; import io.quarkus.oidc.runtime.devui.OidcDevServicesUtils; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; @@ -46,9 +45,7 @@ public class OidcDevUIProcessor extends AbstractDevUIProcessor { private static final String TENANT_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "tenant-enabled"; private static final String DISCOVERY_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "discovery-enabled"; private static final String AUTH_SERVER_URL_CONFIG_KEY = CONFIG_PREFIX + "auth-server-url"; - private static final String APP_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; private static final String OIDC_PROVIDER_CONFIG_KEY = "quarkus.oidc.provider"; - private static final String SERVICE_APP_TYPE = "service"; // Well-known providers @@ -69,13 +66,14 @@ void prepareOidcDevConsole(CuratedApplicationShutdownBuildItem closeBuildItem, BuildProducer cardPageProducer, ConfigurationBuildItem configurationBuildItem, OidcDevUiRecorder recorder, - Optional lightweightDevServicesConfigBuildItem) { - if (!isOidcTenantEnabled() || (!isClientIdSet() && lightweightDevServicesConfigBuildItem.isEmpty())) { + Optional oidcDevServicesConfigBuildItem) { + if (!isOidcTenantEnabled() || (!isClientIdSet() && oidcDevServicesConfigBuildItem.isEmpty())) { return; } final OidcTenantConfig providerConfig = getProviderConfig(); - final String authServerUrl = lightweightDevServicesConfigBuildItem.isPresent() - ? lightweightDevServicesConfigBuildItem.get().getConfig().get(AUTH_SERVER_URL_CONFIG_KEY) + final boolean oidcDevServicesEnabled = oidcDevServicesConfigBuildItem.isPresent(); + final String authServerUrl = oidcDevServicesEnabled + ? oidcDevServicesConfigBuildItem.get().getConfig().get(AUTH_SERVER_URL_CONFIG_KEY) : getAuthServerUrl(providerConfig); if (authServerUrl != null) { if (vertxInstance == null) { @@ -216,19 +214,12 @@ private static String getAuthServerUrl(OidcTenantConfig providerConfig) { } } - private static String getApplicationType(OidcTenantConfig providerConfig) { - Optional appType = ConfigProvider.getConfig().getOptionalValue(APP_TYPE_CONFIG_KEY, - ApplicationType.class); - if (appType.isEmpty() && providerConfig != null) { - appType = providerConfig.applicationType; - } - return appType.isPresent() ? appType.get().name().toLowerCase() : SERVICE_APP_TYPE; - } - private static OidcTenantConfig getProviderConfig() { try { - Provider p = ConfigProvider.getConfig().getValue(OIDC_PROVIDER_CONFIG_KEY, Provider.class); - return KnownOidcProviders.provider(p); + return ConfigProvider.getConfig() + .getOptionalValue(OIDC_PROVIDER_CONFIG_KEY, Provider.class) + .map(KnownOidcProviders::provider) + .orElse(null); } catch (Exception ex) { return null; } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java index 8cd1673a9fc40..c8468c7f002e0 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/KeycloakDevUIProcessor.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.Optional; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.IsDevelopment; @@ -22,6 +23,7 @@ import io.quarkus.oidc.deployment.OidcBuildTimeConfig; import io.quarkus.oidc.deployment.devservices.AbstractDevUIProcessor; import io.quarkus.oidc.runtime.devui.OidcDevJsonRpcService; +import io.quarkus.oidc.runtime.devui.OidcDevLoginObserver; import io.quarkus.oidc.runtime.devui.OidcDevUiRecorder; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; @@ -55,7 +57,7 @@ void produceProviderComponent(Optional confi recorder, capabilities, "Keycloak", - configProps.get().getConfig().get("quarkus.oidc.application-type"), + getApplicationType(), oidcConfig.devui().grant().type().orElse(DevUiConfig.Grant.Type.CODE).getGrantType(), realmUrl + "/protocol/openid-connect/auth", realmUrl + "/protocol/openid-connect/token", @@ -82,18 +84,20 @@ JsonRPCProvidersBuildItem produceOidcDevJsonRpcService() { return new JsonRPCProvidersBuildItem(OidcDevJsonRpcService.class); } + @BuildStep(onlyIf = IsDevelopment.class) + AdditionalBeanBuildItem registerOidcDevLoginObserver() { + // TODO: this is called even when Keycloak DEV UI is disabled and OIDC DEV UI is enabled + // we should fine a mechanism to switch where the endpoints are registered or have shared build steps + return AdditionalBeanBuildItem.unremovableOf(OidcDevLoginObserver.class); + } + @Record(ExecutionTime.RUNTIME_INIT) @BuildStep(onlyIf = IsDevelopment.class) - void invokeEndpoint(BuildProducer routeProducer, - OidcDevUiRecorder recorder, - NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { - routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() - .nestedRoute("io.quarkus.quarkus-oidc", "readSessionCookie") - .handler(recorder.readSessionCookieHandler()) - .build()); - routeProducer.produce(nonApplicationRootPathBuildItem.routeBuilder() - .nestedRoute("io.quarkus.quarkus-oidc", "logout") - .handler(recorder.logoutHandler()) - .build()); + void invokeEndpoint(BuildProducer routeProducer, OidcDevUiRecorder recorder, + NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) { + // TODO: this is called even when Keycloak DEV UI is disabled and OIDC DEV UI is enabled + // we should fine a mechanism to switch where the endpoints are registered or have shared build steps + registerOidcWebAppRoutes(routeProducer, recorder, nonApplicationRootPathBuildItem); } + } diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java deleted file mode 100644 index c122e00272501..0000000000000 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/devservices/keycloak/LightweightDevServicesProcessor.java +++ /dev/null @@ -1,628 +0,0 @@ -package io.quarkus.oidc.deployment.devservices.keycloak; - -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.interfaces.RSAPublicKey; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - -import org.eclipse.microprofile.config.ConfigProvider; -import org.eclipse.microprofile.jwt.Claims; -import org.jboss.logging.Logger; - -import io.quarkus.deployment.IsNormal; -import io.quarkus.deployment.annotations.BuildProducer; -import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.BuildSteps; -import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; -import io.quarkus.deployment.builditem.DevServicesResultBuildItem; -import io.quarkus.deployment.builditem.DevServicesResultBuildItem.RunningDevService; -import io.quarkus.deployment.builditem.DevServicesSharedNetworkBuildItem; -import io.quarkus.deployment.builditem.LaunchModeBuildItem; -import io.quarkus.deployment.console.ConsoleInstalledBuildItem; -import io.quarkus.deployment.dev.devservices.GlobalDevServicesConfig; -import io.quarkus.deployment.logging.LoggingSetupBuildItem; -import io.quarkus.oidc.deployment.OidcBuildStep.IsEnabled; -import io.quarkus.oidc.deployment.OidcBuildTimeConfig; -import io.quarkus.oidc.deployment.devservices.OidcDevServicesBuildItem; -import io.quarkus.runtime.configuration.ConfigUtils; -import io.smallrye.jwt.build.Jwt; -import io.vertx.core.http.HttpServerOptions; -import io.vertx.mutiny.core.Vertx; -import io.vertx.mutiny.core.http.HttpServer; -import io.vertx.mutiny.ext.web.Router; -import io.vertx.mutiny.ext.web.RoutingContext; -import io.vertx.mutiny.ext.web.handler.BodyHandler; - -@BuildSteps(onlyIfNot = IsNormal.class, onlyIf = { IsEnabled.class, GlobalDevServicesConfig.Enabled.class }) -public class LightweightDevServicesProcessor { - - private static final Logger LOG = Logger.getLogger(LightweightDevServicesProcessor.class); - - private static final String CONFIG_PREFIX = "quarkus.oidc."; - private static final String TENANT_ENABLED_CONFIG_KEY = CONFIG_PREFIX + "tenant-enabled"; - private static final String AUTH_SERVER_URL_CONFIG_KEY = CONFIG_PREFIX + "auth-server-url"; - private static final String PROVIDER_CONFIG_KEY = CONFIG_PREFIX + "provider"; - private static final String APPLICATION_TYPE_CONFIG_KEY = CONFIG_PREFIX + "application-type"; - private static final String CLIENT_ID_CONFIG_KEY = CONFIG_PREFIX + "client-id"; - private static final String CLIENT_SECRET_CONFIG_KEY = CONFIG_PREFIX + "credentials.secret"; - - private static volatile RunningDevService devService; - static volatile DevServicesConfig capturedDevServicesConfiguration; - private static volatile boolean first = true; - - OidcBuildTimeConfig oidcConfig; - - private static volatile KeyPair kp; - private static volatile String baseURI; - private static volatile String clientId; - - @BuildStep - public DevServicesResultBuildItem startLightweightServer( - List devServicesSharedNetworkBuildItem, - Optional oidcProviderBuildItem, - KeycloakBuildTimeConfig config, - CuratedApplicationShutdownBuildItem closeBuildItem, - LaunchModeBuildItem launchMode, - Optional consoleInstalledBuildItem, - BuildProducer lightweightBuildItemBuildProducer, - LoggingSetupBuildItem loggingSetupBuildItem, - GlobalDevServicesConfig devServicesConfig) { - - if (oidcProviderBuildItem.isPresent()) { - // Dev Services for the alternative OIDC provider are enabled - return null; - } - - if (!config.devservices.lightweight) { - return null; - } - LOG.info("Starting Lightweight OIDC dev services"); - - DevServicesConfig currentDevServicesConfiguration = config.devservices; - // Figure out if we need to shut down and restart any existing Keycloak container - // if not and the Keycloak container has already started we just return - if (devService != null) { - try { - devService.close(); - } catch (Throwable e) { - LOG.error("Failed to stop lightweight container", e); - } - devService = null; - capturedDevServicesConfiguration = null; - } - capturedDevServicesConfiguration = currentDevServicesConfiguration; - try { - List errors = new ArrayList<>(); - - RunningDevService newDevService = startLightweightServer(lightweightBuildItemBuildProducer, - !devServicesSharedNetworkBuildItem.isEmpty(), - devServicesConfig.timeout, - errors); - if (newDevService == null) { - return null; - } - - devService = newDevService; - - if (first) { - first = false; - Runnable closeTask = new Runnable() { - @Override - public void run() { - if (devService != null) { - try { - devService.close(); - } catch (Throwable t) { - LOG.error("Failed to stop Keycloak container", t); - } - } - first = true; - devService = null; - capturedDevServicesConfiguration = null; - } - }; - closeBuildItem.addCloseTask(closeTask, true); - } - } catch (Throwable t) { - throw new RuntimeException(t); - } - LOG.infof("Dev Services for lightweight OIDC started on %s", baseURI); - - return devService.toBuildItem(); - } - - private RunningDevService startLightweightServer( - BuildProducer lightweightBuildItemBuildProducer, - boolean useSharedNetwork, Optional timeout, - List errors) { - if (!capturedDevServicesConfiguration.enabled) { - // explicitly disabled - LOG.debug("Not starting Dev Services for Keycloak as it has been disabled in the config"); - return null; - } - if (!isOidcTenantEnabled()) { - LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.tenant.enabled' is false"); - return null; - } - if (ConfigUtils.isPropertyPresent(AUTH_SERVER_URL_CONFIG_KEY)) { - LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.auth-server-url' has been provided"); - return null; - } - if (ConfigUtils.isPropertyPresent(PROVIDER_CONFIG_KEY)) { - LOG.debug("Not starting Dev Services for Keycloak as 'quarkus.oidc.provider' has been provided"); - return null; - } - - Vertx vertx = Vertx.vertx(); - HttpServerOptions options = new HttpServerOptions(); - options.setPort(0); - HttpServer httpServer = vertx.createHttpServer(options); - - Router router = Router.router(vertx); - httpServer.requestHandler(router); - registerRoutes(router); - - httpServer.listenAndAwait(); - int port = httpServer.actualPort(); - - Map configProperties = new HashMap<>(); - baseURI = "http://localhost:" + port; - clientId = getOidcClientId(); - String oidcClientSecret = getOidcClientSecret(); - String oidcApplicationType = getOidcApplicationType(); - configProperties.put(AUTH_SERVER_URL_CONFIG_KEY, baseURI); - configProperties.put(APPLICATION_TYPE_CONFIG_KEY, oidcApplicationType); - configProperties.put(CLIENT_ID_CONFIG_KEY, clientId); - configProperties.put(CLIENT_SECRET_CONFIG_KEY, oidcClientSecret); - - lightweightBuildItemBuildProducer - .produce(new LightweightDevServicesConfigBuildItem(configProperties)); - - return new RunningDevService("oidc-lightweight", null, () -> { - LOG.info("Closing Vertx DEV service for oidc lightweight"); - vertx.closeAndAwait(); - }, configProperties); - } - - private void registerRoutes(Router router) { - BodyHandler bodyHandler = BodyHandler.create(); - router.get("/").handler(this::mainRoute); - router.get("/.well-known/openid-configuration").handler(this::configuration); - router.get("/authorize").handler(this::authorize); - router.post("/login").handler(bodyHandler).handler(this::login); - router.post("/token").handler(bodyHandler).handler(this::accessTokenJson); - router.get("/keys").handler(this::getKeys); - router.get("/logout").handler(this::logout); - - KeyPairGenerator kpg; - try { - kpg = KeyPairGenerator.getInstance("RSA"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - kpg.initialize(2048); - kp = kpg.generateKeyPair(); - } - - private List getUsers() { - if (capturedDevServicesConfiguration.roles.isEmpty()) { - return Arrays.asList("alice", "bob"); - } else { - List ret = new ArrayList<>(capturedDevServicesConfiguration.roles.keySet()); - Collections.sort(ret); - return ret; - } - } - - private List getUserRoles(String user) { - List roles = capturedDevServicesConfiguration.roles.get(user); - return roles == null ? ("alice".equals(user) ? List.of("admin", "user") : List.of("user")) - : roles; - } - - private static boolean isOidcTenantEnabled() { - return ConfigProvider.getConfig().getOptionalValue(TENANT_ENABLED_CONFIG_KEY, Boolean.class).orElse(true); - } - - private static String getOidcApplicationType() { - return ConfigProvider.getConfig().getOptionalValue(APPLICATION_TYPE_CONFIG_KEY, String.class).orElse("service"); - } - - private static String getOidcClientId() { - return ConfigProvider.getConfig().getOptionalValue(CLIENT_ID_CONFIG_KEY, String.class) - .orElse("quarkus-app"); - } - - private static String getOidcClientSecret() { - return ConfigProvider.getConfig().getOptionalValue(CLIENT_SECRET_CONFIG_KEY, String.class) - .orElse("the secret must be 32 characters at least to avoid a warning"); - } - - private void mainRoute(RoutingContext rc) { - rc.response().endAndForget("Lightweight OIDC server up and running"); - } - - private void configuration(RoutingContext rc) { - String data = "{\n" - + " \"token_endpoint\":\"" + baseURI + "/token\",\n" - + " \"token_endpoint_auth_methods_supported\":[\n" - + " \"client_secret_post\",\n" - + " \"private_key_jwt\",\n" - + " \"client_secret_basic\"\n" - + " ],\n" - + " \"jwks_uri\":\"" + baseURI + "/keys\",\n" - + " \"response_modes_supported\":[\n" - + " \"query\",\n" - + " \"fragment\",\n" - + " \"form_post\"\n" - + " ],\n" - + " \"subject_types_supported\":[\n" - + " \"pairwise\"\n" - + " ],\n" - + " \"id_token_signing_alg_values_supported\":[\n" - + " \"RS256\"\n" - + " ],\n" - + " \"response_types_supported\":[\n" - + " \"code\",\n" - + " \"id_token\",\n" - + " \"code id_token\",\n" - + " \"id_token token\"\n" - + " ],\n" - + " \"scopes_supported\":[\n" - + " \"openid\",\n" - + " \"profile\",\n" - + " \"email\",\n" - + " \"offline_access\"\n" - + " ],\n" - + " \"issuer\":\"" + baseURI + "/lightweight\",\n" - + " \"request_uri_parameter_supported\":false,\n" - + " \"userinfo_endpoint\":\"" + baseURI + "/userinfo\",\n" - + " \"authorization_endpoint\":\"" + baseURI + "/authorize\",\n" - + " \"device_authorization_endpoint\":\"" + baseURI + "/devicecode\",\n" - + " \"http_logout_supported\":true,\n" - + " \"frontchannel_logout_supported\":true,\n" - + " \"end_session_endpoint\":\"" + baseURI + "/logout\",\n" - + " \"claims_supported\":[\n" - + " \"sub\",\n" - + " \"iss\",\n" - + " \"aud\",\n" - + " \"exp\",\n" - + " \"iat\",\n" - + " \"auth_time\",\n" - + " \"acr\",\n" - + " \"nonce\",\n" - + " \"preferred_username\",\n" - + " \"name\",\n" - + " \"tid\",\n" - + " \"ver\",\n" - + " \"at_hash\",\n" - + " \"c_hash\",\n" - + " \"email\"\n" - + " ]\n" - + "}"; - rc.response().putHeader("Content-Type", "application/json"); - rc.endAndForget(data); - } - - /* - * First request: - * GET - * https://localhost:X/authorize?response_type=code&client_id=SECRET&scope=openid+openid+ - * email+profile&redirect_uri=http://localhost:8080/Login/oidcLoginSuccess&state=STATE - * - * returns a 302 to - * GET http://localhost:8080/Login/oidcLoginSuccess?code=CODE&state=STATE - */ - private void authorize(RoutingContext rc) { - String response_type = rc.request().params().get("response_type"); - String clientId = rc.request().params().get("client_id"); - String scope = rc.request().params().get("scope"); - String state = rc.request().params().get("state"); - String redirect_uri = rc.request().params().get("redirect_uri"); - UUID code = UUID.randomUUID(); - URI redirect; - try { - redirect = new URI(redirect_uri + "?state=" + state); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - StringBuilder predefinedUsers = new StringBuilder(); - for (String predefinedUser : getUsers()) { - predefinedUsers.append(" \n"); - } - rc.response() - .endAndForget("" - + " " - + " Login" - + " \n" - + " \n" - + " \n" - + "
\n" - + "
\n" - + "
Login
\n" - + "
\n" - + "
\n" - + "
\n" - + " \n" - + predefinedUsers - + "
\n" - + "
\n" - + " Custom user\n" - + "
\n" - + " " - + "
" - + "
" - + " \n" - + "
\n" - + "
\n" - + "
\n" - + "
\n" - + " \n" - + ""); - } - - private void login(RoutingContext rc) { - String redirect_uri = rc.request().params().get("redirect_uri"); - String predefined = rc.request().params().get("predefined"); - String name = rc.request().params().get("name"); - String roles = rc.request().params().get("roles"); - if (predefined != null) { - name = predefined; - roles = String.join(",", getUserRoles(name)); - } - if (name == null || name.isBlank()) { - name = "user"; - } - // store user|roles in the code param as Base64 - String code = Base64.getUrlEncoder().encodeToString((name + "|" + roles).getBytes(StandardCharsets.UTF_8)); - rc.response() - .putHeader("Location", redirect_uri + "&code=" + code) - .setStatusCode(302) - .endAndForget(); - } - - /* - * OIDC calls POST /token - * grant_type=authorization_code - * &code=CODE - * &redirect_uri=URI - * - * returns: - * - * { - * "token_type":"Bearer", - * "scope":"openid email profile", - * "expires_in":3600, - * "ext_expires_in":3600, - * "access_token":TOKEN, - * "id_token":JWT - * } - * - * ID token: - * { - * "ver": "2.0", - * "iss": "http://localhost/lightweight", - * "sub": "USERID", - * "aud": "CLIENTID", - * "exp": 1641906214, - * "iat": 1641819514, - * "nbf": 1641819514, - * "name": "Foo Bar", - * "preferred_username": "user@example.com", - * "oid": "OPAQUE", - * "email": "user@example.com", - * "tid": "TENANTID", - * "aio": "AZURE_OPAQUE" - * } - */ - private void accessTokenJson(RoutingContext rc) { - String authorization_code = rc.request().formAttributes().get("authorization_code"); - String code = rc.request().formAttributes().get("code"); - String redirect_uri = rc.request().formAttributes().get("redirect_uri"); - String decodedCode = new String(Base64.getUrlDecoder().decode(code), StandardCharsets.UTF_8); - int separator = decodedCode.indexOf('|'); - String user = decodedCode.substring(0, separator); - String rolesAsString = decodedCode.substring(separator + 1); - Set roles = new HashSet<>(Arrays.asList(rolesAsString.split("[,\\s]+"))); - - String accessToken = Jwt.claims() - .expiresIn(Duration.ofDays(1)) - .issuedAt(Instant.now()) - .issuer(baseURI + "/lightweight") - .subject(user) - .upn(user) - // not sure if the next three are even used - .claim("name", "Foo Bar") - .claim(Claims.preferred_username, user + "@example.com") - .claim(Claims.email, user + "@example.com") - .groups(roles) - .jws() - .keyId("KEYID") - .sign(kp.getPrivate()); - String idToken = Jwt.claims() - .expiresIn(Duration.ofDays(1)) - .issuedAt(Instant.now()) - .issuer(baseURI + "/lightweight") - .audience(clientId) - .subject(user) - .upn(user) - .claim("name", "Foo Bar") - .claim(Claims.preferred_username, user + "@example.com") - .claim(Claims.email, user + "@example.com") - .groups(roles) - .jws() - .keyId("KEYID") - .sign(kp.getPrivate()); - - String data = "{\n" - + " \"token_type\":\"Bearer\",\n" - + " \"scope\":\"openid email profile\",\n" - + " \"expires_in\":3600,\n" - + " \"ext_expires_in\":3600,\n" - + " \"access_token\":\"" + accessToken + "\",\n" - + " \"id_token\":\"" + idToken + "\"\n" - + " } "; - rc.response() - .putHeader("Content-Type", "application/json") - .endAndForget(data); - } - - /* - * {"kty":"RSA", - * "use":"sig", - * "kid":"KEYID", - * "x5t":"KEYID", - * "n": - * "", - * "e":"", - * "x5c":[ - * "KEYID" - * ], - * "issuer":"http://localhost/lightweight"}, - */ - private void getKeys(RoutingContext rc) { - RSAPublicKey pub = (RSAPublicKey) kp.getPublic(); - String modulus = Base64.getUrlEncoder().encodeToString(pub.getModulus().toByteArray()); - String exponent = Base64.getUrlEncoder().encodeToString(pub.getPublicExponent().toByteArray()); - String data = "{\n" - + " \"keys\": [\n" - + " {\n" - + " \"alg\": \"RS256\",\n" - + " \"kty\": \"RSA\",\n" - + " \"n\": \"" + modulus + "\",\n" - + " \"use\": \"sig\",\n" - + " \"kid\": \"KEYID\",\n" - + " \"k5t\": \"KEYID\",\n" - + " \"issuer\": \"" + baseURI + "/lightweight\",\n" - + " \"e\": \"" + exponent + "\"\n" - + " },\n" - + " ]\n" - + "}"; - rc.response() - .putHeader("Content-Type", "application/json") - .endAndForget(data); - } - - /* - * /logout - * ?post_logout_redirect_uri=URI - * &id_token_hint=SECRET - */ - private void logout(RoutingContext rc) { - // we have no cookie state - String redirect_uri = rc.request().params().get("post_logout_redirect_uri"); - rc.response() - .putHeader("Location", redirect_uri) - .setStatusCode(302) - .endAndForget(); - } -} diff --git a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js index 2b1e92e9f9539..4def29c9866cf 100644 --- a/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js +++ b/extensions/oidc/deployment/src/main/resources/dev-ui/qwc-oidc-provider.js @@ -1,20 +1,19 @@ -import { QwcHotReloadElement, html, css} from 'qwc-hot-reload-element'; +import {css, html, QwcHotReloadElement} from 'qwc-hot-reload-element'; import {classMap} from 'lit/directives/class-map.js'; import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import {JsonRpc} from 'jsonrpc'; -import { LitState } from 'lit-element-state'; +import {LitState} from 'lit-element-state'; import '@vaadin/button'; import '@vaadin/details'; import '@vaadin/horizontal-layout'; +import '@vaadin/checkbox'; import '@vaadin/icon'; import '@vaadin/message-list'; import '@vaadin/password-field'; import '@vaadin/split-layout'; -import { notifier } from 'notifier'; -import { Router } from '@vaadin/router'; -import { - devRoot -} from 'build-time-data'; +import {notifier} from 'notifier'; +import {Router} from '@vaadin/router'; +import {devRoot} from 'build-time-data'; /** * This keeps state of OIDC properties that can potentially change on hot reload. @@ -44,12 +43,14 @@ class OidcPropertiesState extends LitState { postLogoutUriParam: null, scopes: null, authExtraParams: null, - httpPort: 0, + httpPort: 8080, accessToken: null, idToken: null, userName: null, propertiesStateId: null, - testServiceResponses: null + testServiceResponses: null, + servicePathRequired: true, + webAppLoginObserver: null }; } @@ -78,6 +79,12 @@ class OidcPropertiesState extends LitState { propertiesState.authExtraParams = response.result.authExtraParams; propertiesState.httpPort = response.result.httpPort; propertiesState.oidcProviderName = response.result.oidcProviderName; + if (propertiesState.oidcApplicationType !== response.result.oidcApplicationType + && response.result.oidcApplicationType === 'web-app') { + // default OIDC application type has changed and this is a web app + // reset checkbox to default + propertiesState.servicePathRequired = false + } propertiesState.oidcApplicationType = response.result.oidcApplicationType; propertiesState.oidcGrantType = response.result.oidcGrantType; propertiesState.swaggerIsAvailable = response.result.swaggerIsAvailable; @@ -232,9 +239,11 @@ export class QwcOidcProvider extends QwcHotReloadElement { .half-width { width: 50%; } - .jwt-tooltip { - cursor: help; - background: rgba(0, 0, 0, .1); + .jwt-tooltip-bg { + background: rgba(0, 0, 0, .1); + } + .jwt-tooltip-cursor { + cursor: url("data:image/svg+xml,%3Csvg height='0.8rem' width='0.8rem' fill='%23000000' viewBox='0 0 318.293 318.293' xml:space='preserve' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cg%3E%3Cpath d='M 159.148,0 C 106.452,0 63.604,39.326 63.604,87.662 h 47.736 c 0,-22.007 21.438,-39.927 47.808,-39.927 26.367,0 47.804,17.92 47.804,39.927 v 6.929 c 0,23.39 -10.292,34.31 -25.915,50.813 -20.371,21.531 -45.744,48.365 -45.744,105.899 h 47.745 c 0,-38.524 15.144,-54.568 32.692,-73.12 17.368,-18.347 38.96,-41.192 38.96,-83.592 V 87.662 C 254.689,39.326 211.845,0 159.148,0 Z' style='fill:%234087d4;fill-opacity:1' /%3E%3Crect x='134.475' y='277.996' width='49.968' height='40.297' style='fill:%234087d4;fill-opacity:1' /%3E%3C/g%3E%3C/svg%3E"), help; } `; @@ -248,13 +257,12 @@ export class QwcOidcProvider extends QwcHotReloadElement { _selectedClientId: {state: false, type: String}, _selectedClientSecret: {state: false, type: String}, _servicePath: {state: false, type: String}, - _devRoot: {state: false, type: String} + _devRoot: {state: false, type: String}, }; constructor() { super(); this._devRoot = (devRoot?.replaceAll('/', '%2F') ?? '') + 'dev-ui'; // e.g. /q/dev-ui - this._selectedRealm = null; this._servicePath = '/'; this._selectedClientId = null; @@ -300,6 +308,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'idToken'); propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'testServiceResponses'); propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'accessToken'); + propertiesState.addObserver(this.conditionalUpdatePropertiesStateObserver, 'servicePathRequired'); super.connectedCallback(); QwcOidcProvider._loadProperties(this.jsonRpc) @@ -373,6 +382,16 @@ export class QwcOidcProvider extends QwcHotReloadElement { return html` + + ${servicePathForm} { - // logged in + // logged out + + propertiesState.hideImplicitLoggedIn = true; + propertiesState.userName = null; + + if (QwcOidcProvider._isErrorInUrl()) { propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = false; + } else { propertiesState.hideLogInErr = true; - propertiesState.hideImplicitLoggedIn = false; - onUpdateDone(); - }, () => { - // logged out - propertiesState.hideImplicitLoggedIn = true; - propertiesState.userName = null; - - if (QwcOidcProvider._isErrorInUrl()) { - propertiesState.hideImplLoggedOut = true; - propertiesState.hideLogInErr = false; - } else { - propertiesState.hideLogInErr = true; - propertiesState.hideImplLoggedOut = false; - } - - propertiesState.accessToken = null; - propertiesState.idToken = null; - onUpdateDone(); - }); + propertiesState.hideImplLoggedOut = false; + } + + propertiesState.accessToken = null; + propertiesState.idToken = null; + onUpdateDone(); } } @@ -1122,9 +1145,57 @@ export class QwcOidcProvider extends QwcHotReloadElement { } } + static _cancelWebAppLoginObserver() { + if (propertiesState.webAppLoginObserver !== null) { + propertiesState.webAppLoginObserver.cancel(); + propertiesState.webAppLoginObserver = null; + } + } + + static _checkSessionCookieAndUpdateState(jsonRpc, onUpdateDone) { + QwcOidcProvider._checkSessionCookie(jsonRpc, () => { + // logged in + propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = true; + propertiesState.hideImplicitLoggedIn = false; + + QwcOidcProvider._cancelWebAppLoginObserver(); + + onUpdateDone(); + }, () => { + // logged out + propertiesState.hideImplicitLoggedIn = true; + propertiesState.userName = null; + + if (QwcOidcProvider._isErrorInUrl()) { + propertiesState.hideImplLoggedOut = true; + propertiesState.hideLogInErr = false; + } else { + propertiesState.hideLogInErr = true; + propertiesState.hideImplLoggedOut = false; + } + + propertiesState.accessToken = null; + propertiesState.idToken = null; + + if (propertiesState.webAppLoginObserver === null) { + propertiesState.webAppLoginObserver = jsonRpc.streamOidcLoginEvent().onNext(jsonRpcResponse => { + const isLoggedIn = jsonRpcResponse?.result; + if (isLoggedIn) { + QwcOidcProvider._cancelWebAppLoginObserver(); + QwcOidcProvider._checkSessionCookieAndUpdateState(jsonRpc, onUpdateDone); + } + }); + } + + onUpdateDone(); + }); + } + static _checkSessionCookie(jsonRpc, onLoggedIn, onLoggedOut) { - // FIXME: port, path? - fetch("http://localhost:8080/q/io.quarkus.quarkus-oidc/readSessionCookie") + // FIXME: hardcoded path? + const port = propertiesState.httpPort ?? 8080 + fetch("http://localhost:" + port + "/q/io.quarkus.quarkus-oidc/readSessionCookie") .then(response => response.json()) .then(result => { if ("id_token" in result || "access_token" in result) { @@ -1194,7 +1265,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { const clientId = this._getClientId(); let address; - if (propertiesState.keycloakAdminUrl && this._selectedRealm) { + if (!QwcOidcProvider._isWebApp() && propertiesState.keycloakAdminUrl && this._selectedRealm) { address = propertiesState.keycloakAdminUrl + '/realms/' + this._selectedRealm + '/protocol/openid-connect/logout'; } else { address = propertiesState.logoutUrl; @@ -1215,8 +1286,8 @@ export class QwcOidcProvider extends QwcHotReloadElement { const signature = parts[2]?.trim(); return html` - ${headers}.${payload}.${signature} + ${headers}.${payload}.${signature} `; } else if (parts.length === 5) { const headers = parts[0]?.trim(); @@ -1226,10 +1297,10 @@ export class QwcOidcProvider extends QwcHotReloadElement { const authTag = parts[4]?.trim(); return html` - ${headers}.${encryptedKey}.${initVector}.${ciphertext}.${authTag} + ${headers}.${encryptedKey}.${initVector}.${ciphertext}.${authTag} `; } else { return html`${token}`; @@ -1251,10 +1322,22 @@ export class QwcOidcProvider extends QwcHotReloadElement { "iss": "Issuer", "sub": "Subject", "aud": "Audience", - "nbf": "Not Before", - "iat": "Issued At", "exp": "Expiration Time", - "jti": "JWT ID" + "iat": "Issued At", + "auth_time": "End-User Authentication Time", + "nonce": "Cryptographic Nonce", + "acr": "Authentication Context Class Reference", + "amr": "Authentication Methods References", + "azp": "Authorized Party", + "nbf": "Not Before", + "jti": "JWT ID", + "sid": "Session ID", + "scope": "Scope", + "upn": "User Principal Name", + "groups": "Groups", + "kid": "Key ID", + "alg": "Algorithm", + "typ": "Token Type" }; const spaces = 4; var ret = "{"; @@ -1269,7 +1352,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { // decorate key var tooltip = tooltips[k]; if(tooltip) { - ret += "\"" + k + "\""; + ret += "\"" + k + "\""; } else { ret += "\"" + k + "\""; } @@ -1277,7 +1360,7 @@ export class QwcOidcProvider extends QwcHotReloadElement { ret += ": "; // decorate values if(k == 'iat' || k == 'nbf' || k == 'exp'){ - ret += "" + val + ""; + ret += "" + val + ""; } else { ret += JSON.stringify(val); } @@ -1297,14 +1380,16 @@ export class QwcOidcProvider extends QwcHotReloadElement { const parts = token.split("."); if (parts.length === 3) { const headers = QwcOidcProvider._decodeBase64(parts[0]); + const headersJsonObj = JSON.parse(headers); + const headersHtml = QwcOidcProvider._formatJson(headersJsonObj); const payload = QwcOidcProvider._decodeBase64(parts[1]); const signature = parts[2]; const jsonPayload = JSON.parse(payload); const json = QwcOidcProvider._formatJson(jsonPayload); return html` -
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
-
${unsafeHTML(json?.trim())}
- ${signature?.trim()} +
${unsafeHTML(headersHtml?.trim())}
+
${unsafeHTML(json?.trim())}
+ ${signature?.trim()} `; } else if (parts.length === 5) { const headers = window.atob(parts[0]?.trim()); @@ -1314,11 +1399,11 @@ export class QwcOidcProvider extends QwcHotReloadElement { const authTag = parts[4]?.trim(); return html` -
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
-
${encryptedKey}
-
${initVector}
-
${ciphertext}
- ${authTag} +
${JSON.stringify(JSON.parse(headers), null, 4)?.trim()}
+
${encryptedKey}
+
${initVector}
+
${ciphertext}
+ ${authTag} `; } else { return html`${token}`; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java index 2ab60d98f32fd..2944e03ffa7ae 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevJsonRpcService.java @@ -4,11 +4,13 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; import org.eclipse.microprofile.config.ConfigProvider; import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.smallrye.common.annotation.NonBlocking; +import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import io.vertx.core.Vertx; @@ -16,6 +18,9 @@ public class OidcDevJsonRpcService { private OidcDevUiRpcSvcPropertiesBean props; private HttpConfiguration httpConfiguration; + @Inject + OidcDevLoginObserver oidcDevTokensObserver; + private Vertx vertx; @PostConstruct @@ -63,6 +68,10 @@ public Uni testServiceWithClientCred(String tokenUrl, String serviceUrl, props.getWebClientTimeout(), props.getClientCredGrantOptions()); } + public Multi streamOidcLoginEvent() { + return oidcDevTokensObserver.streamOidcLoginEvent(); + } + public void hydrate(OidcDevUiRpcSvcPropertiesBean properties, HttpConfiguration httpConfiguration) { this.props = properties; this.httpConfiguration = httpConfiguration; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java new file mode 100644 index 0000000000000..cc5ab5b683d6b --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevLoginObserver.java @@ -0,0 +1,70 @@ +package io.quarkus.oidc.runtime.devui; + +import java.time.Duration; +import java.util.function.Function; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import io.quarkus.oidc.SecurityEvent; +import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.operators.multi.processors.BroadcastProcessor; +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class OidcDevLoginObserver { + + private final BroadcastProcessor oidcLoginStream; + + OidcDevLoginObserver(OidcConfig config) { + boolean isWebApplication = ApplicationType.WEB_APP == OidcConfig.getDefaultTenant(config).applicationType() + .orElse(null); + if (isWebApplication) { + this.oidcLoginStream = BroadcastProcessor.create(); + } else { + this.oidcLoginStream = null; + } + } + + void observeOidcLogin(@Observes SecurityEvent event) { + if (oidcLoginStream != null && event.getEventType() == SecurityEvent.Type.OIDC_LOGIN) { + RoutingContext routingContext = event.getSecurityIdentity().getAttribute(RoutingContext.class.getName()); + if (routingContext != null && !routingContext.response().ended()) { + routingContext.addEndHandler(new Handler>() { + @Override + public void handle(AsyncResult voidAsyncResult) { + oidcLoginStream.onNext(true); + } + }); + } else { + oidcLoginStream.onNext(true); + } + } + } + + Multi streamOidcLoginEvent() { + return oidcLoginStream == null ? Multi.createFrom().empty() : oidcLoginStream.onItem().call(delayByOneSecond()); + } + + private static Function> delayByOneSecond() { + return new Function>() { + @Override + public Uni apply(Boolean i) { + if (Boolean.TRUE.equals(i)) { + // we inform about login once response has ended, + // but we need to wait a bit till response is sent and cookies present on the browser side + // if this proves unreliable, we can add retry on the front end side instead of the delay + return Uni.createFrom().item(true).onItem().delayIt().by(Duration.ofSeconds(1)); + } else { + return Uni.createFrom().nothing(); + } + } + }; + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java index 32401f5bb49f2..cba0d21d1de04 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionCookieReaderHandler.java @@ -1,11 +1,8 @@ package io.quarkus.oidc.runtime.devui; -import java.util.regex.Pattern; - -import org.jboss.logging.Logger; - import io.quarkus.arc.Arc; import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.runtime.DefaultTokenStateManager; import io.quarkus.oidc.runtime.OidcConfig; import io.quarkus.oidc.runtime.OidcUtils; @@ -14,19 +11,21 @@ import io.vertx.core.http.Cookie; import io.vertx.ext.web.RoutingContext; -public class OidcDevSessionCookieReaderHandler implements Handler { - private static final Logger LOG = Logger.getLogger(OidcDevSessionCookieReaderHandler.class); - static final String COOKIE_DELIM = "|"; - static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM); +final class OidcDevSessionCookieReaderHandler implements Handler { + + private final OidcTenantConfig defaultTenantConfig; + + OidcDevSessionCookieReaderHandler(OidcConfig oidcConfig) { + this.defaultTenantConfig = OidcTenantConfig.of(OidcConfig.getDefaultTenant(oidcConfig)); + } @Override public void handle(RoutingContext rc) { Cookie cookie = rc.request().getCookie(OidcUtils.SESSION_COOKIE_NAME); if (cookie != null) { DefaultTokenStateManager tokenStateManager = Arc.container().instance(DefaultTokenStateManager.class).get(); - OidcConfig oidcConfig = Arc.container().instance(OidcConfig.class).get(); - Uni tokensUni = tokenStateManager.getTokens(rc, oidcConfig.defaultTenant, - cookie.getValue(), null); + Uni tokensUni = tokenStateManager.getTokens(rc, defaultTenantConfig, cookie.getValue(), + null); tokensUni.subscribe().with(tokens -> { rc.response().setStatusCode(200); rc.response().putHeader("Content-Type", "application/json"); @@ -34,7 +33,7 @@ public void handle(RoutingContext rc) { + "\", \"refresh_token\": \"" + tokens.getRefreshToken() + "\"}"); - }); + }, rc::fail); } else { rc.response().setStatusCode(200); rc.response().putHeader("Content-Type", "application/json"); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLoginHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLoginHandler.java new file mode 100644 index 0000000000000..708131edc5e14 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLoginHandler.java @@ -0,0 +1,91 @@ +package io.quarkus.oidc.runtime.devui; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.net.URLEncoder; + +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.UnauthorizedException; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +final class OidcDevSessionLoginHandler implements Handler { + + private static final String REDIRECT_URI_PARAM = "oidc-provider-redirect-uri"; + + /** + * Quarkus OIDC provider card page URI. + */ + private volatile String providerRedirectUri; + + @Override + public void handle(RoutingContext rc) { + // this method enforces authentication and redirects back to the original page + // it is used when you click on the "login" button + + if (redirectUriMissing(rc)) { + // our OIDC provider page JS should set this param + rc.fail(500, new IllegalStateException("Query param 'oidc-provider-redirect-uri' is missing")); + return; + } + + HttpAuthenticator httpAuthenticator = rc.get(HttpAuthenticator.class.getName()); + if (httpAuthenticator != null) { + QuarkusHttpUser + .getSecurityIdentity(rc, null) + .onItem().transformToUni(identity -> { + if (identity != null && !identity.isAnonymous()) { + return Uni.createFrom().item(identity); + } else { + // most likely no credentials and we need to send challenge + return Uni.createFrom().failure(new UnauthorizedException()); + } + }) + .subscribe().with(identity -> redirectBackToOidcProviderCardPage(rc, null), + ignored -> httpAuthenticator.sendChallenge(rc).subscribe().with(challengeResult -> { + // challenge redirecting to OIDC provider + if (!rc.response().ended()) { + rc.response().end(); + } + }, challengeFailure -> { + // this shouldn't happen, illegal state + if (!rc.response().ended()) { + redirectBackToOidcProviderCardPage(rc, challengeFailure.getMessage()); + } + })); + } else { + // this will only happen if there is a bug in Quarkus + redirectBackToOidcProviderCardPage(rc, "Failed to authenticate as HttpAuthenticator is missing"); + } + } + + private boolean redirectUriMissing(RoutingContext rc) { + if (providerRedirectUri == null) { + final String requestRedirectUri = rc.request().getParam(REDIRECT_URI_PARAM); + if (requestRedirectUri == null) { + return true; + } else { + providerRedirectUri = requestRedirectUri; + } + } + return false; + } + + private void redirectBackToOidcProviderCardPage(RoutingContext rc, String errorDescription) { + final String oidcProviderCardPage; + if (errorDescription != null) { + oidcProviderCardPage = providerRedirectUri + "?error_description=" + URLEncoder.encode(errorDescription, UTF_8); + } else { + oidcProviderCardPage = providerRedirectUri; + } + rc + .response() + .setStatusCode(HttpResponseStatus.FOUND.code()) + .putHeader(HttpHeaders.LOCATION, oidcProviderCardPage) + .end(); + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java index d6ae3cb7c8cb5..49d924add3836 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevSessionLogoutHandler.java @@ -1,14 +1,11 @@ package io.quarkus.oidc.runtime.devui; -import org.jboss.logging.Logger; - import io.quarkus.oidc.runtime.OidcUtils; import io.vertx.core.Handler; import io.vertx.core.http.impl.ServerCookie; import io.vertx.ext.web.RoutingContext; -public class OidcDevSessionLogoutHandler implements Handler { - private static final Logger LOG = Logger.getLogger(OidcDevSessionLogoutHandler.class); +final class OidcDevSessionLogoutHandler implements Handler { @Override public void handle(RoutingContext rc) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java index 5c31936dbdfb1..474989627d6e5 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/devui/OidcDevUiRecorder.java @@ -5,6 +5,7 @@ import java.util.Map; import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.oidc.runtime.OidcConfig; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; import io.quarkus.vertx.http.runtime.HttpConfiguration; @@ -14,6 +15,12 @@ @Recorder public class OidcDevUiRecorder { + private final RuntimeValue oidcConfigRuntimeValue; + + public OidcDevUiRecorder(RuntimeValue oidcConfigRuntimeValue) { + this.oidcConfigRuntimeValue = oidcConfigRuntimeValue; + } + public void createJsonRPCService(BeanContainer beanContainer, RuntimeValue oidcDevUiRpcSvcPropertiesBean, HttpConfiguration httpConfiguration) { OidcDevJsonRpcService jsonRpcService = beanContainer.beanInstance(OidcDevJsonRpcService.class); @@ -34,10 +41,14 @@ public RuntimeValue getRpcServiceProperties(Strin } public Handler readSessionCookieHandler() { - return new OidcDevSessionCookieReaderHandler(); + return new OidcDevSessionCookieReaderHandler(oidcConfigRuntimeValue.getValue()); } public Handler logoutHandler() { return new OidcDevSessionLogoutHandler(); } + + public Handler loginHandler() { + return new OidcDevSessionLoginHandler(); + } } diff --git a/integration-tests/oidc-dev-services/pom.xml b/integration-tests/oidc-dev-services/pom.xml new file mode 100644 index 0000000000000..eace1af7f7741 --- /dev/null +++ b/integration-tests/oidc-dev-services/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-oidc-dev-services + Quarkus - Integration Tests - Dev Services for OIDC + Dev Services for OIDC integration tests module + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + org.htmlunit + htmlunit + test + + + org.eclipse.jetty + * + + + + + + io.quarkus + quarkus-rest-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-oidc-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + maven-surefire-plugin + + + maven-failsafe-plugin + + + io.quarkus + quarkus-maven-plugin + + + + generate-code + build + + + + + + + + diff --git a/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java b/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java new file mode 100644 index 0000000000000..dcc3a947e72a9 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/SecuredResource.java @@ -0,0 +1,59 @@ +package io.quarkus.it.oidc.dev.services; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.IdToken; +import io.quarkus.oidc.UserInfo; +import io.quarkus.security.identity.SecurityIdentity; + +@Path("secured") +public class SecuredResource { + + @Inject + SecurityIdentity securityIdentity; + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + UserInfo userInfo; + + @Inject + @ConfigProperty(name = "quarkus.oidc.application-type", defaultValue = "service") + String applicationType; + + @Inject + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + String serverUrl; + + @RolesAllowed("admin") + @GET + @Path("admin-only") + public String getAdminOnly() { + return (isWebApp() ? idToken.getName() : securityIdentity.getPrincipal().getName()) + " " + securityIdentity.getRoles(); + } + + @RolesAllowed("user") + @GET + @Path("user-only") + public String getUserOnly() { + return userInfo.getPreferredUserName() + " " + securityIdentity.getRoles(); + } + + @GET + @Path("auth-server-url") + public String getAuthServerUrl() { + return serverUrl; + } + + private boolean isWebApp() { + return "web-app".equals(applicationType); + } +} diff --git a/integration-tests/oidc-dev-services/src/main/resources/application.properties b/integration-tests/oidc-dev-services/src/main/resources/application.properties new file mode 100644 index 0000000000000..636d87caec1ef --- /dev/null +++ b/integration-tests/oidc-dev-services/src/main/resources/application.properties @@ -0,0 +1,3 @@ +quarkus.oidc.devservices.enabled=true + +%code-flow.quarkus.oidc.application-type=web-app diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java new file mode 100644 index 0000000000000..2bf25252718b7 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesIT.java @@ -0,0 +1,8 @@ +package io.quarkus.it.oidc.dev.services; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class BearerAuthenticationOidcDevServicesIT extends BearerAuthenticationOidcDevServicesTest { + +} diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java new file mode 100644 index 0000000000000..eac0592af5e07 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/BearerAuthenticationOidcDevServicesTest.java @@ -0,0 +1,77 @@ +package io.quarkus.it.oidc.dev.services; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +public class BearerAuthenticationOidcDevServicesTest { + + @Test + public void testLoginAsCustomUser() { + RestAssured.given() + .auth().oauth2(getAccessToken("Ronald", "admin")) + .get("/secured/admin-only") + .then() + .statusCode(200) + .body(Matchers.containsString("Ronald")) + .body(Matchers.containsString("admin")); + RestAssured.given() + .auth().oauth2(getAccessToken("Ronald", "admin")) + .get("/secured/user-only") + .then() + .statusCode(403); + } + + @Test + public void testLoginAsAlice() { + RestAssured.given() + .auth().oauth2(getAccessToken("alice")) + .get("/secured/admin-only") + .then() + .statusCode(200) + .body(Matchers.containsString("alice")) + .body(Matchers.containsString("admin")) + .body(Matchers.containsString("user")); + RestAssured.given() + .auth().oauth2(getAccessToken("alice")) + .get("/secured/user-only") + .then() + .statusCode(200) + .body(Matchers.containsString("alice")) + .body(Matchers.containsString("admin")) + .body(Matchers.containsString("user")); + } + + @Test + public void testLoginAsBob() { + RestAssured.given() + .auth().oauth2(getAccessToken("bob")) + .get("/secured/admin-only") + .then() + .statusCode(403); + RestAssured.given() + .auth().oauth2(getAccessToken("bob")) + .get("/secured/user-only") + .then() + .statusCode(200) + .body(Matchers.containsString("bob")) + .body(Matchers.containsString("user")); + } + + private String getAccessToken(String user) { + return RestAssured.given().get(getAuthServerUrl() + "/testing/generate/access-token?user=" + user).asString(); + } + + private String getAccessToken(String user, String... roles) { + return RestAssured.given() + .get(getAuthServerUrl() + "/testing/generate/access-token?user=" + user + "&roles=" + String.join(",", roles)) + .asString(); + } + + private static String getAuthServerUrl() { + return RestAssured.get("/secured/auth-server-url").then().statusCode(200).extract().body().asString(); + } +} diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java new file mode 100644 index 0000000000000..1ff5e97a19666 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/CodeFlowOidcDevServicesTest.java @@ -0,0 +1,127 @@ +package io.quarkus.it.oidc.dev.services; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.net.URI; + +import org.htmlunit.FailingHttpStatusCodeException; +import org.htmlunit.SilentCssErrorHandler; +import org.htmlunit.TextPage; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlPage; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.RestAssured; + +@QuarkusTest +@TestProfile(CodeFlowOidcDevServicesTest.OidcWebAppTestProfile.class) +public class CodeFlowOidcDevServicesTest { + + @Test + public void testLoginAsCustomUser() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/secured/admin-only"); + + Assertions.assertEquals("Login", page.getTitleText()); + + HtmlForm loginForm = page.getForms().stream().filter(form -> "custom-form".equals(form.getAttribute("class"))) + .findFirst().get(); + + loginForm.getInputByName("name").setValueAttribute("Ronald"); + loginForm.getInputByName("roles").setValueAttribute("admin,user"); + + TextPage adminOnlyPage = loginForm.getButtonByName("login").click(); + Assertions.assertTrue(adminOnlyPage.getContent().contains("Ronald")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("admin")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("user")); + + assertNotNull(webClient.getCookieManager().getCookie("q_session")); + + testLogout(webClient); + } + } + + @Test + public void testLoginAsAlice() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/secured/admin-only"); + + Assertions.assertEquals("Login", page.getTitleText()); + + HtmlForm loginForm = page.getForms().stream().filter(form -> "predefined-form".equals(form.getAttribute("class"))) + .findFirst().get(); + + TextPage adminOnlyPage = loginForm.getButtonByName("predefined-alice").click(); + Assertions.assertTrue(adminOnlyPage.getContent().contains("alice")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("admin")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("user")); + + testLogout(webClient); + } + } + + @Test + public void testLoginAsBob() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/secured/user-only"); + + Assertions.assertEquals("Login", page.getTitleText()); + + HtmlForm loginForm = page.getForms().stream().filter(form -> "predefined-form".equals(form.getAttribute("class"))) + .findFirst().get(); + + TextPage adminOnlyPage = loginForm.getButtonByName("predefined-bob").click(); + Assertions.assertTrue(adminOnlyPage.getContent().contains("bob")); + Assertions.assertTrue(adminOnlyPage.getContent().contains("user")); + Assertions.assertFalse(adminOnlyPage.getContent().contains("admin")); + + try { + webClient.getPage("http://localhost:8081/secured/admin-only"); + fail("Exception is expected because Bob is not admin"); + } catch (FailingHttpStatusCodeException ex) { + Assertions.assertEquals(403, ex.getStatusCode()); + } + + testLogout(webClient); + } + } + + private static void testLogout(WebClient webClient) throws IOException { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest(URI.create(getAuthServerUrl() + + "/logout?post_logout_redirect_uri=north-pole&id_token_hint=SECRET") + .toURL())); + Assertions.assertEquals(302, webResponse.getStatusCode()); + Assertions.assertEquals("north-pole", webResponse.getResponseHeaderValue("Location")); + + webClient.getCookieManager().clearCookies(); + } + + private static WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } + + private static String getAuthServerUrl() { + return RestAssured.get("/secured/auth-server-url").then().statusCode(200).extract().body().asString(); + } + + public static class OidcWebAppTestProfile implements QuarkusTestProfile { + @Override + public String getConfigProfile() { + return "code-flow"; + } + } + +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 6376d450f75fa..55f67dc439690 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -257,6 +257,7 @@ oidc-client-registration oidc-client-reactive oidc-client-wiremock + oidc-dev-services oidc-mtls oidc-token-propagation oidc-token-propagation-reactive