From 6c03ff437fbae810e441dbbee376ba44ae277b28 Mon Sep 17 00:00:00 2001 From: Mohamed Ali BARKA Date: Tue, 6 Oct 2026 20:20:35 +0200 Subject: [PATCH] feat(oidc): OIDC authentification --- README.md | 49 ++++++++ compose.yaml | 4 +- pom.xml | 55 +++++---- .../tech/illuin/wombat/ui/CurrentUser.java | 56 +++++++++ .../tech/illuin/wombat/ui/RouteConfig.java | 41 +++++++ .../illuin/wombat/ui/SessionController.java | 34 ++++++ .../java/tech/illuin/wombat/ui/Templates.java | 2 + .../java/tech/illuin/wombat/ui/UIConfig.java | 17 +++ .../resources/{vendor => public}/dmsans.css | 4 +- .../resources/{ => public}/easepick-theme.css | 0 .../{ => public/icons}/wombat-large-mask.png | Bin .../{ => public/icons}/wombat-medium-mask.png | Bin .../resources/{ => public}/impact.css | 15 +++ .../META-INF/resources/{ => public}/impact.js | 0 .../resources/{ => public}/impactError.js | 0 .../resources/{ => public}/impactUtils.js | 0 src/main/resources/application-dev.yaml.dist | 26 +++++ src/main/resources/application.yaml | 31 +++++ src/main/resources/templates/impact.html | 2 +- src/main/resources/templates/impactError.html | 4 +- .../resources/templates/impactLayout.html | 22 +++- src/main/resources/templates/loggedOut.html | 14 +++ .../wombat/ui/AuthenticationEnabledTest.java | 109 ++++++++++++++++++ .../illuin/wombat/ui/UIControllerTest.java | 77 +++++++++++++ 24 files changed, 529 insertions(+), 33 deletions(-) create mode 100644 src/main/java/tech/illuin/wombat/ui/CurrentUser.java create mode 100644 src/main/java/tech/illuin/wombat/ui/RouteConfig.java create mode 100644 src/main/java/tech/illuin/wombat/ui/SessionController.java create mode 100644 src/main/java/tech/illuin/wombat/ui/UIConfig.java rename src/main/resources/META-INF/resources/{vendor => public}/dmsans.css (86%) rename src/main/resources/META-INF/resources/{ => public}/easepick-theme.css (100%) rename src/main/resources/META-INF/resources/{ => public/icons}/wombat-large-mask.png (100%) rename src/main/resources/META-INF/resources/{ => public/icons}/wombat-medium-mask.png (100%) rename src/main/resources/META-INF/resources/{ => public}/impact.css (94%) rename src/main/resources/META-INF/resources/{ => public}/impact.js (100%) rename src/main/resources/META-INF/resources/{ => public}/impactError.js (100%) rename src/main/resources/META-INF/resources/{ => public}/impactUtils.js (100%) create mode 100644 src/main/resources/templates/loggedOut.html create mode 100644 src/test/java/tech/illuin/wombat/ui/AuthenticationEnabledTest.java diff --git a/README.md b/README.md index e7e2c0e..28c8263 100644 --- a/README.md +++ b/README.md @@ -368,3 +368,52 @@ To package it without running tests, append `-DskipTests`: This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) ; see the [LICENSE](LICENSE) file for details. Copyright (c) 2026 Illuin Technology. + +## Access Control + +Wombat can optionally restrict access to the UI and REST endpoints using OpenID Connect. Authentication is disabled by default (`quarkus.oidc.tenant-enabled: false`). + +To enable it, configure your OIDC provider in `application.yaml` (or via environment variables): + +```yaml +quarkus: + oidc: + tenant-enabled: true + auth-server-url: https://your-idp.example.com/realms/your-realm + # Or use a pre-configured Quarkus provider shortcut (e.g. google, github, microsoft) instead of auth-server-url: + # provider: google + application-type: hybrid + client-id: + credentials: + secret: + authentication: + redirect-path: /auth/callback + restore-path-after-redirect: true + http: + auth: + permission: + public: + enabled: ${quarkus.oidc.tenant-enabled} + policy: permit + paths: + - /q/metrics + - /q/health + - /q/health/* + - /logout + - /public/* + authenticated: + enabled: ${quarkus.oidc.tenant-enabled} + policy: authenticated + paths: + - /* +``` + +When running behind a reverse proxy or ingress terminating TLS, enable forwarded headers so the OIDC redirect URI is resolved with `https`: + +```yaml +quarkus: + http: + proxy: + proxy-address-forwarding: true + enable-forwarded-host: true +``` \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index d406366..fb7ffca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: wombat POSTGRES_DB: wombat volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql boavizta: image: ghcr.io/boavizta/boaviztapi:2.4.1 @@ -45,7 +45,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" volumes: - - ./app/compose/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - ./compose/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus grafana: diff --git a/pom.xml b/pom.xml index 0ce8c9f..85e3a8f 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,25 @@ 1.2.1 + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + org.testcontainers + testcontainers-bom + 1.20.4 + pom + import + + + + tech.illuin @@ -167,6 +186,19 @@ io.quarkus quarkus-smallrye-health + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-test-security-oidc + test + software.amazon.awssdk s3 @@ -248,25 +280,6 @@ - - - - ${quarkus.platform.group-id} - ${quarkus.platform.artifact-id} - ${quarkus.platform.version} - pom - import - - - org.testcontainers - testcontainers-bom - ${testcontainers.version} - pom - import - - - - @@ -422,7 +435,7 @@ https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriCZOIHTWEBlw.woff2 - ${project.build.outputDirectory}/META-INF/resources/vendor/fonts + ${project.build.outputDirectory}/META-INF/resources/public/fonts dmsans-latin.woff2 97e85d9e72e66a605e3defbf6749ba7811e52f584b9447e7ad969b046b045b34ffa6870460552c1f46df78dbf3011889216541bba88019eb727661ebb9d89b6f @@ -435,7 +448,7 @@ https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriCZ2IHTWEBlwu8Q.woff2 - ${project.build.outputDirectory}/META-INF/resources/vendor/fonts + ${project.build.outputDirectory}/META-INF/resources/public/fonts dmsans-latin-ext.woff2 8caa8310822b3328d2e394143ca8dca25a1a2bc5b0ef0ad29f14dfd8160d5a4440ca427fdfbfa58b686de28edf62e2938a09fed6282c5c2da5e07d4f4675e5ef diff --git a/src/main/java/tech/illuin/wombat/ui/CurrentUser.java b/src/main/java/tech/illuin/wombat/ui/CurrentUser.java new file mode 100644 index 0000000..00fc380 --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/CurrentUser.java @@ -0,0 +1,56 @@ +package tech.illuin.wombat.ui; + +import io.quarkus.oidc.UserInfo; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.json.JsonString; +import org.eclipse.microprofile.jwt.JsonWebToken; + +/** + * Represents the currently signed-in user for Qute templates (exposed as {@code currentUser} in {@link UIConfig}). + */ +public record CurrentUser( + boolean authenticated, + String name +) +{ + /** {@code io.quarkus.oidc.runtime.OidcUtils#USER_INFO_ATTRIBUTE} */ + private static final String USER_INFO_ATTRIBUTE = "userinfo"; + private static final String EMAIL_CLAIM = "email"; + + static final CurrentUser ANONYMOUS = new CurrentUser(false, null); + + static CurrentUser from(SecurityIdentity identity) + { + if (identity == null || identity.isAnonymous()) + return ANONYMOUS; + + return new CurrentUser(true, name(identity)); + } + + private static String name(SecurityIdentity identity) + { + if (identity.getPrincipal() instanceof JsonWebToken token) + { + String claim = asString(token.getClaim(EMAIL_CLAIM)); + if (claim != null && !claim.isBlank()) + return claim; + } + + UserInfo userInfo = identity.getAttribute(USER_INFO_ATTRIBUTE); + if (userInfo != null && userInfo.getEmail() != null && !userInfo.getEmail().isBlank()) + return userInfo.getEmail(); + + return identity.getPrincipal() == null ? null : identity.getPrincipal().getName(); + } + + private static String asString(Object value) + { + return switch (value) + { + case null -> null; + case String string -> string; + case JsonString json -> json.getString(); + default -> value.toString(); + }; + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/RouteConfig.java b/src/main/java/tech/illuin/wombat/ui/RouteConfig.java new file mode 100644 index 0000000..dcb04c9 --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/RouteConfig.java @@ -0,0 +1,41 @@ +package tech.illuin.wombat.ui; + +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.Router; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ApplicationScoped +public class RouteConfig +{ + private static final String DASHBOARD_PATH = "/"; + + private static final Logger logger = LoggerFactory.getLogger(RouteConfig.class); + + public void registerDashboardFallback(@Observes Router router) + { + router.route().order(Integer.MAX_VALUE).handler(context -> { + HttpMethod method = context.request().method(); + boolean readOnly = HttpMethod.GET.equals(method) || HttpMethod.HEAD.equals(method); + boolean oidcCallback = context.request().getParam("code") != null && context.request().getParam("state") != null; + + if (!readOnly || oidcCallback) + { + context.next(); + return; + } + + String query = context.request().query(); + String target = query == null || query.isBlank() ? DASHBOARD_PATH : DASHBOARD_PATH + "?" + query; + + logger.debug("No route for {}, redirecting to {}", context.request().uri(), target); + context.response() + .setStatusCode(302) + .putHeader(HttpHeaders.LOCATION, target) + .end(); + }); + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/SessionController.java b/src/main/java/tech/illuin/wombat/ui/SessionController.java new file mode 100644 index 0000000..ad4c6cb --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/SessionController.java @@ -0,0 +1,34 @@ +package tech.illuin.wombat.ui; + +import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.OidcSession; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("logout") +public class SessionController +{ + private final OidcSession oidcSession; + private final SecurityIdentity identity; + + public SessionController(OidcSession oidcSession, SecurityIdentity identity) + { + this.oidcSession = oidcSession; + this.identity = identity; + } + + @GET + @Produces(MediaType.TEXT_HTML) + public Uni logout() + { + if (this.identity.getCredential(IdTokenCredential.class) == null) + return Uni.createFrom().item(Templates.loggedOut()); + + return this.oidcSession.logout().replaceWith(() -> Templates.loggedOut()); + } +} diff --git a/src/main/java/tech/illuin/wombat/ui/Templates.java b/src/main/java/tech/illuin/wombat/ui/Templates.java index 55541de..c692753 100644 --- a/src/main/java/tech/illuin/wombat/ui/Templates.java +++ b/src/main/java/tech/illuin/wombat/ui/Templates.java @@ -22,6 +22,8 @@ public static native TemplateInstance impact( public static native TemplateInstance impactError(TimeRange timeRange, MaxSpan maxSpan); + public static native TemplateInstance loggedOut(); + public record MaxSpan(long millis, String label) {} public record AssetSelection(List all, List selectedIds) {} diff --git a/src/main/java/tech/illuin/wombat/ui/UIConfig.java b/src/main/java/tech/illuin/wombat/ui/UIConfig.java new file mode 100644 index 0000000..a977b6f --- /dev/null +++ b/src/main/java/tech/illuin/wombat/ui/UIConfig.java @@ -0,0 +1,17 @@ +package tech.illuin.wombat.ui; + +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Named; + +@ApplicationScoped +public class UIConfig +{ + @Named("currentUser") + @Dependent + public CurrentUser provideCurrentUser(SecurityIdentity identity) + { + return CurrentUser.from(identity); + } +} diff --git a/src/main/resources/META-INF/resources/vendor/dmsans.css b/src/main/resources/META-INF/resources/public/dmsans.css similarity index 86% rename from src/main/resources/META-INF/resources/vendor/dmsans.css rename to src/main/resources/META-INF/resources/public/dmsans.css index 083a004..b8f4422 100644 --- a/src/main/resources/META-INF/resources/vendor/dmsans.css +++ b/src/main/resources/META-INF/resources/public/dmsans.css @@ -7,7 +7,7 @@ font-style: normal; font-weight: 400 700; font-display: swap; - src: url(/vendor/fonts/dmsans-latin-ext.woff2) format('woff2'); + src: url(/public/fonts/dmsans-latin-ext.woff2) format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @@ -16,6 +16,6 @@ font-style: normal; font-weight: 400 700; font-display: swap; - src: url(/vendor/fonts/dmsans-latin.woff2) format('woff2'); + src: url(/public/fonts/dmsans-latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } diff --git a/src/main/resources/META-INF/resources/easepick-theme.css b/src/main/resources/META-INF/resources/public/easepick-theme.css similarity index 100% rename from src/main/resources/META-INF/resources/easepick-theme.css rename to src/main/resources/META-INF/resources/public/easepick-theme.css diff --git a/src/main/resources/META-INF/resources/wombat-large-mask.png b/src/main/resources/META-INF/resources/public/icons/wombat-large-mask.png similarity index 100% rename from src/main/resources/META-INF/resources/wombat-large-mask.png rename to src/main/resources/META-INF/resources/public/icons/wombat-large-mask.png diff --git a/src/main/resources/META-INF/resources/wombat-medium-mask.png b/src/main/resources/META-INF/resources/public/icons/wombat-medium-mask.png similarity index 100% rename from src/main/resources/META-INF/resources/wombat-medium-mask.png rename to src/main/resources/META-INF/resources/public/icons/wombat-medium-mask.png diff --git a/src/main/resources/META-INF/resources/impact.css b/src/main/resources/META-INF/resources/public/impact.css similarity index 94% rename from src/main/resources/META-INF/resources/impact.css rename to src/main/resources/META-INF/resources/public/impact.css index d7c18ad..d2773f1 100644 --- a/src/main/resources/META-INF/resources/impact.css +++ b/src/main/resources/META-INF/resources/public/impact.css @@ -56,6 +56,13 @@ body { background: var(--bg); color: var(--text); font-family: var(--sans); font .theme-toggle:hover { color: var(--accent-green); border-color: var(--accent-green); } .theme-toggle:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(22,163,74,0.18); } .header-meta + .theme-toggle { margin-left: 12px; } +.header-user { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; max-width: 320px; font-size: 12px; color: var(--text-dim); background: var(--surface2); border: 1px solid var(--border); padding: 4px 6px 4px 12px; border-radius: 20px; } +.header-meta + .header-user { margin-left: 12px; } +.header-user + .theme-toggle { margin-left: 12px; } +.header-user-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-logout { flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); text-decoration: none; transition: color 0.15s, background 0.15s; } +.header-logout:hover { color: var(--accent-green); background: var(--surface); } +.header-logout:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(22,163,74,0.18); } .theme-toggle .icon-sun { display: none; } .theme-toggle .icon-moon { display: block; } [data-theme="dark"] .theme-toggle .icon-sun { display: block; } @@ -182,6 +189,8 @@ table { min-width: 600px; } @media (max-width: 900px) { .overview-row, .metrics-row { grid-template-columns: 1fr; } } @media (max-width: 640px) { .header-meta { display: none; } + .header-user { padding: 4px 6px; } + .header-user-name { display: none; } .main { padding: 16px; } .filter-bar { padding: 12px 16px; gap: 12px; } .filter-group { width: 100%; } @@ -268,6 +277,12 @@ tbody td.metric.bold { font-weight: 700; } .chart-pill.yellow.active { background: var(--accent-yellow-soft); border-color: var(--accent-yellow); color: var(--accent-yellow); } .chart-note { margin-top: 10px; font-size: 11px; color: var(--text-dim); line-height: 1.5; } +/* ── Notice card (sign-out confirmation) ── */ +.notice-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 36px 40px; display: flex; flex-direction: column; gap: 12px; max-width: 560px; box-shadow: var(--shadow); } +.notice-title { font-size: 18px; font-weight: 700; color: var(--text); } +.notice-body { font-size: 13px; color: var(--text-muted); line-height: 1.6; } +.btn-link { align-self: flex-start; display: inline-block; text-decoration: none; } + /* ── Date range picker ── */ .date-range-group { flex-wrap: wrap; align-items: flex-start; } .date-range-picker { background: var(--surface2); border: 1.5px solid var(--border); color: var(--text); font-family: var(--sans); font-size: 13px; padding: 8px 12px; border-radius: var(--radius-sm); outline: none; cursor: pointer; min-width: 320px; transition: border-color 0.15s, box-shadow 0.15s; } diff --git a/src/main/resources/META-INF/resources/impact.js b/src/main/resources/META-INF/resources/public/impact.js similarity index 100% rename from src/main/resources/META-INF/resources/impact.js rename to src/main/resources/META-INF/resources/public/impact.js diff --git a/src/main/resources/META-INF/resources/impactError.js b/src/main/resources/META-INF/resources/public/impactError.js similarity index 100% rename from src/main/resources/META-INF/resources/impactError.js rename to src/main/resources/META-INF/resources/public/impactError.js diff --git a/src/main/resources/META-INF/resources/impactUtils.js b/src/main/resources/META-INF/resources/public/impactUtils.js similarity index 100% rename from src/main/resources/META-INF/resources/impactUtils.js rename to src/main/resources/META-INF/resources/public/impactUtils.js diff --git a/src/main/resources/application-dev.yaml.dist b/src/main/resources/application-dev.yaml.dist index a956258..63d225b 100644 --- a/src/main/resources/application-dev.yaml.dist +++ b/src/main/resources/application-dev.yaml.dist @@ -17,6 +17,32 @@ connector: monitor: environments-file: monitored/monitored-environments-dev.yaml +# quarkus: +# oidc: +# tenant-enabled: true +# auth-server-url: https://accounts.google.com +# application-type: hybrid +# client-id: .apps.googleusercontent.com +# credentials: +# secret: +# authentication: +# scopes: +# - email +# - profile +# user-info-required: true +# redirect-path: /auth/callback +# restore-path-after-redirect: true +# extra-params: +# prompt: select_account +# http: +# auth: +# permission: +# dev-ui: +# policy: permit +# paths: +# - /q/dev/* +# - /q/dev-ui/* + # Local S3 Backup setup (e.g. SeaweedFS service) backup: enabled: true diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0f4b471..19d1727 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,6 +4,22 @@ quarkus: http: limits: max-initial-line-length: 32768 + auth: + permission: + public: + enabled: ${quarkus.oidc.tenant-enabled} + policy: permit + paths: + - /q/metrics + - /q/health + - /q/health/* + - /logout + - /public/* + authenticated: + enabled: ${quarkus.oidc.tenant-enabled} + policy: authenticated + paths: + - /* rest-client: logging: scope: none @@ -42,6 +58,21 @@ quarkus: kubernetes-client: devservices: enabled: false + oidc: + tenant-enabled: false + #oidc: + # tenant-enabled: true + # auth-server-url: https://your-idp.example.com/realms/your-realm + # application-type: hybrid + # client-id: my-client-id + # credentials: + # secret: my-client-secret + # authentication: + # redirect-path: /auth/callback + # restore-path-after-redirect: true + keycloak: + devservices: + enabled: false micrometer: binder: netty: diff --git a/src/main/resources/templates/impact.html b/src/main/resources/templates/impact.html index d9eebc6..3ac6b31 100644 --- a/src/main/resources/templates/impact.html +++ b/src/main/resources/templates/impact.html @@ -549,6 +549,6 @@ {/if} {#include impactScript.html /} - + {/include} diff --git a/src/main/resources/templates/impactError.html b/src/main/resources/templates/impactError.html index 791cddc..eb135ae 100644 --- a/src/main/resources/templates/impactError.html +++ b/src/main/resources/templates/impactError.html @@ -1,8 +1,8 @@ {#include impactLayout.html} {#headScripts} - - + + {/headScripts} diff --git a/src/main/resources/templates/impactLayout.html b/src/main/resources/templates/impactLayout.html index 10966b8..eccb4cc 100644 --- a/src/main/resources/templates/impactLayout.html +++ b/src/main/resources/templates/impactLayout.html @@ -4,18 +4,30 @@ Impact Report - - - - + + + + {#insert headScripts /}
- +

Wombat — Environmental Impact

{#insert headerExtra /} + {#if inject:currentUser.authenticated} +
+ {inject:currentUser.name} + + + + + + + +
+ {/if}