Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
FROM maven:3.6.0-jdk-11-slim AS build
FROM maven:3.8.6-eclipse-temurin-11 AS build

RUN apt-get update && \
apt-get install -y git mongodb
# Removed MongoDB, cause it has own container.

COPY . /webprotege

WORKDIR /webprotege

RUN mkdir -p /data/db \
&& mongod --fork --syslog \
&& mvn clean package
# Docker build only needs to compile and package. Database-backed checks belong
# in CI or local dev (Testcontainers, compose stack, etc.), not necessarily
# inside every docker build.
# @todo Use Testcontainers for that.

RUN mvn -B -DskipTests clean package

FROM tomcat:8-jre11-slim

Expand All @@ -19,11 +21,11 @@ RUN rm -rf /usr/local/tomcat/webapps/* \

WORKDIR /usr/local/tomcat/webapps/ROOT

# Here WEBPROTEGE_VERSION is coming from the custom build args WEBPROTEGE_VERSION=$DOCKER_TAG hooks/build script.
# Ref: https://docs.docker.com/docker-hub/builds/advanced/
ARG WEBPROTEGE_VERSION
# WEBPROTEGE_VERSION must match the project's version in the parent pom (currently 5.0.0-SNAPSHOT).
# Set default value, so we can override it in the docker-compose.yml file.
ARG WEBPROTEGE_VERSION=5.0.0-SNAPSHOT

ENV WEBPROTEGE_VERSION $WEBPROTEGE_VERSION
ENV WEBPROTEGE_VERSION=$WEBPROTEGE_VERSION
COPY --from=build /webprotege/webprotege-cli/target/webprotege-cli-${WEBPROTEGE_VERSION}.jar /webprotege-cli.jar
COPY --from=build /webprotege/webprotege-server/target/webprotege-server-${WEBPROTEGE_VERSION}.war ./webprotege.war
RUN unzip webprotege.war \
Expand Down
31 changes: 31 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,34 @@ Sharing the volumes used by the WebProtégé app and MongoDB allow to keep persi
* MongoDB will store its data in the source code folder at `./.protegedata/mongodb` where you run `docker-compose`

> Path to the shared volumes can be changed in the `docker-compose.yml` file.

OIDC (OpenID Connect SSO)
-------------------------

WebProtégé can delegate sign-in to any OIDC-compatible identity provider (for example Keycloak) using the **authorization code flow**. The server loads `{issuer}/.well-known/openid-configuration`, redirects users to the provider, exchanges the code for tokens, validates the ID token, then provisions or matches a **local** WebProtégé user and starts a normal session.

OIDC is **not** configured in `webprotege.properties`. Set **environment variables** on the JVM process (or equivalent JVM `-D` system properties; environment wins over `-D`).

**Required** (all three must be set; if any is missing, OIDC stays disabled and local login works as usual):

| Variable | Purpose |
|----------|---------|
| `WEBPROTEGE_OIDC_ISSUER_URI` | Issuer base URL, e.g. `https://auth.example.com/realms/myrealm` (used to discover authorization, token, and JWKS endpoints). |
| `WEBPROTEGE_OIDC_CLIENT_ID` | OIDC client id registered at the provider. |
| `WEBPROTEGE_OIDC_CLIENT_SECRET` | Client secret for a **confidential** client. |

**Optional**:

| Variable | Default / behavior |
|----------|-------------------|
| `WEBPROTEGE_OIDC_REDIRECT_URI` | If unset, the callback URL is derived from the incoming HTTP request. For reverse proxies, ensure the public URL the browser sees matches what you register at the IdP. Callback path is always `…/webprotege/oidc/callback`. |
| `WEBPROTEGE_OIDC_SCOPES` | `openid profile email` |
| `WEBPROTEGE_OIDC_USERNAME_CLAIM` | `preferred_username` — used to **link** OIDC accounts to local users; must match an existing username or a new local user is created on first login. |
| `WEBPROTEGE_OIDC_HIDE_LOCAL_LOGIN` | When `true`, the username/password form is hidden and users rely on OIDC (login URL: `…/webprotege/oidc/login`). |

**IdP registration**: Register a redirect URI of the form `https://<your-public-host><context>/webprotege/oidc/callback` (same scheme/host as end users use).

**JVM equivalents** (if you prefer `-D` instead of env): `webprotege.oidc.issuer.uri`, `webprotege.oidc.client.id`, `webprotege.oidc.client.secret`, `webprotege.oidc.redirect.uri`, `webprotege.oidc.scopes`, `webprotege.oidc.username.claim`, `webprotege.oidc.hide.local.login`.

In Docker Compose, add variables under the `webprotege` service `environment:` list (same names as above).

Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,16 @@ public interface Messages extends com.google.gwt.i18n.client.Messages {
String signInToContinue();


@DefaultMessage("Sign in with Keycloak")
@Key("signInWithKeycloak")
String signInWithKeycloak();


@DefaultMessage("Single sign-on failed. Try again or use username and password.")
@Key("login_oidc_failed")
String login_oidc_failed();


@DefaultMessage("Sign Out")
@Key("signOut")
String signOut();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package edu.stanford.bmir.protege.web.client.auth;

import com.google.gwt.user.client.Window;

/**
* Reads OIDC bootstrap values set in {@code WebProtege.jsp} from environment-driven server configuration.
*/
public final class ClientOidcConfig {

private ClientOidcConfig() {
}

public static boolean isOidcSsoEnabled() {
return readSsoEnabled();
}

public static boolean isOidcHideLocalLogin() {
return readHideLocalLogin();
}

private static native boolean readSsoEnabled() /*-{
return !!$wnd.webprotegeOidcSsoEnabled;
}-*/;

private static native boolean readHideLocalLogin() /*-{
return !!$wnd.webprotegeOidcHideLocalLogin;
}-*/;

public static String getOidcLoginUrl() {
String url = readLoginUrl();
if (url == null || url.isEmpty()) {
return fallbackLoginUrl();
}
return url;
}

private static String fallbackLoginUrl() {
String path = Window.Location.getPath();
if (path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
if (path.endsWith("/WebProtege.jsp")) {
path = path.substring(0, path.length() - "/WebProtege.jsp".length());
}
return path + "/webprotege/oidc/login";
}

private static native String readLoginUrl() /*-{
var u = $wnd.webprotegeOidcLoginUrl;
return u == null ? null : String(u);
}-*/;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package edu.stanford.bmir.protege.web.client.login;

import com.google.gwt.user.client.Window;
import edu.stanford.bmir.protege.web.client.auth.ClientOidcConfig;
import com.google.gwt.place.shared.Place;
import com.google.gwt.place.shared.PlaceController;
import com.google.gwt.user.client.ui.AcceptsOneWidget;
Expand Down Expand Up @@ -72,6 +74,13 @@ public LoginPresenter(@Nonnull LoginView view,
public void start(@Nonnull AcceptsOneWidget container, @Nonnull EventBus eventBus) {
view.clearView();
view.hideErrorMessages();
if (ClientOidcConfig.isOidcSsoEnabled()) {
view.configureOidcLogin(ClientOidcConfig.getOidcLoginUrl(), ClientOidcConfig.isOidcHideLocalLogin());
}
String oidcError = Window.Location.getParameter("oidc_error");
if (oidcError != null && !oidcError.isEmpty()) {
view.showOidcLoginFailedMessage();
}
boolean canCreateUser = loggedInUserManager.isAllowedApplicationAction(CREATE_ACCOUNT);
view.setSignUpForAccountVisible(canCreateUser);
container.setWidget(view);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,8 @@ public interface LoginView extends IsWidget, RequiresResize, ProvidesResize {
void setSignUpForAccountHandler(@Nonnull SignUpForAccountHandler handler);

void setSignUpForAccountVisible(boolean visible);

void configureOidcLogin(@Nonnull String loginUrl, boolean hideLocalLogin);

void showOidcLoginFailedMessage();
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ interface LoginViewImplUiBinder extends UiBinder<HTMLPanel, LoginViewImpl> {

private static LoginViewImplUiBinder ourUiBinder = GWT.create(LoginViewImplUiBinder.class);

@UiField
protected Anchor oidcLoginAnchor;

@UiField
protected HTMLPanel localLoginFieldsPanel;

@UiField
protected TextBox userNameField;

Expand Down Expand Up @@ -121,6 +127,9 @@ public void clearView() {
userNameField.setText("");
passwordField.setText("");
hideErrorMessages();
oidcLoginAnchor.setVisible(false);
oidcLoginAnchor.setHref("#");
localLoginFieldsPanel.setVisible(true);
}

@Override
Expand Down Expand Up @@ -163,6 +172,20 @@ public void setSignUpForAccountVisible(boolean visible) {
signUpForAccountButton.setVisible(visible);
}

@Override
public void configureOidcLogin(@Nonnull String loginUrl, boolean hideLocalLogin) {
oidcLoginAnchor.setHref(loginUrl);
oidcLoginAnchor.setText(MESSAGES.signInWithKeycloak());
oidcLoginAnchor.setTarget("_self");
oidcLoginAnchor.setVisible(true);
localLoginFieldsPanel.setVisible(!hideLocalLogin);
}

@Override
public void showOidcLoginFailedMessage() {
messageBox.showAlert(MESSAGES.login_oidc_failed());
}

@Override
public void onResize() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@

<g:Label text="{msg.signInToContinue}" addStyleNames="{wp.login.loginMessage} {style.message}"/>

<g:HTMLPanel addStyleNames="{style.login-form} {wp.login.loginForm}">
<g:Anchor ui:field="oidcLoginAnchor" visible="false"
addStyleNames="{wp.buttons.button} {wp.buttons.pageButton} {wp.buttons.primaryButton}"
href="#"/>

<g:HTMLPanel ui:field="localLoginFieldsPanel" addStyleNames="{style.login-form} {wp.login.loginForm}">
<g:Label text="{msg.userName}" addStyleNames="{wp.style.formLabel}"/>
<g:TextBox ui:field="userNameField" visibleLength="30"/>
<div style="height: 10px;"/>
Expand Down
12 changes: 12 additions & 0 deletions webprotege-server-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-all</artifactId>
Expand Down Expand Up @@ -208,6 +214,12 @@
<version>${jackson.version}</version>
</dependency>

<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.3</version>
</dependency>

<dependency>
<groupId>com.atlassian.commonmark</groupId>
<artifactId>commonmark</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package edu.stanford.bmir.protege.web.server.auth.oidc;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.proc.BadJOSEException;
import com.nimbusds.jwt.JWTClaimsSet;
import edu.stanford.bmir.protege.web.shared.user.UserId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.inject.Inject;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.text.ParseException;
import java.util.Base64;

/**
* Builds the Keycloak/OIDC authorization redirect and completes the code exchange.
*/
public class OidcAuthCoordinator {

private static final Logger logger = LoggerFactory.getLogger(OidcAuthCoordinator.class);

public static final String SESSION_STATE = "webprotege.oidc.state";

public static final String SESSION_NONCE = "webprotege.oidc.nonce";

public static final String SESSION_REDIRECT_URI = "webprotege.oidc.redirect_uri";

private static final SecureRandom RANDOM = new SecureRandom();

private final OidcRuntimeConfig config;

private final OidcDiscoveryCache discoveryCache;

private final OidcHttpTokenClient tokenClient;

private final OidcSsoUserProvisioner userProvisioner;

@Inject
public OidcAuthCoordinator(@Nonnull OidcRuntimeConfig config,
@Nonnull OidcDiscoveryCache discoveryCache,
@Nonnull OidcHttpTokenClient tokenClient,
@Nonnull OidcSsoUserProvisioner userProvisioner) {
this.config = config;
this.discoveryCache = discoveryCache;
this.tokenClient = tokenClient;
this.userProvisioner = userProvisioner;
}

public boolean isEnabled() {
return config.isEnabled();
}

@Nonnull
public String buildAuthorizationRedirectUrl(@Nonnull HttpSession session,
@Nonnull String redirectUriForCallback) throws IOException {
OidcDiscoveryDocument discovery = discoveryCache.get(config.getIssuerUri());
String state = randomUrlSafe(24);
String nonce = randomUrlSafe(24);
session.setAttribute(SESSION_STATE, state);
session.setAttribute(SESSION_NONCE, nonce);
session.setAttribute(SESSION_REDIRECT_URI, redirectUriForCallback);
String scope = URLEncoder.encode(config.getScopes(), StandardCharsets.UTF_8);
String redirectEnc = URLEncoder.encode(redirectUriForCallback, StandardCharsets.UTF_8);
return discovery.getAuthorizationEndpoint()
+ "?response_type=code"
+ "&client_id=" + URLEncoder.encode(config.getClientId(), StandardCharsets.UTF_8)
+ "&redirect_uri=" + redirectEnc
+ "&scope=" + scope
+ "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8)
+ "&nonce=" + URLEncoder.encode(nonce, StandardCharsets.UTF_8);
}

@Nonnull
public UserId completeLogin(@Nonnull HttpSession session,
@Nonnull String code,
@Nonnull String state) throws IOException, ParseException, BadJOSEException, JOSEException {
Object expectedState = session.getAttribute(SESSION_STATE);
Object expectedNonce = session.getAttribute(SESSION_NONCE);
Object redirectUriObj = session.getAttribute(SESSION_REDIRECT_URI);
if (!(expectedState instanceof String) || !(expectedNonce instanceof String) || !(redirectUriObj instanceof String)) {
throw new IOException("Missing OIDC session state; restart sign-in from WebProtege.");
}
if (!((String) expectedState).equals(state)) {
throw new IOException("Invalid OIDC state parameter");
}
String redirectUri = (String) redirectUriObj;
String nonce = (String) expectedNonce;
OidcDiscoveryDocument discovery = discoveryCache.get(config.getIssuerUri());
OidcTokenResponse tokenResponse = tokenClient.exchangeAuthorizationCode(
discovery.getTokenEndpoint(),
code,
redirectUri,
config.getClientId(),
config.getClientSecret());
JWTClaimsSet claims = OidcIdTokenValidator.validateAndParseClaims(
tokenResponse.getIdToken(),
discovery.getIssuer(),
discovery.getJwksUri(),
config.getClientId());
String tokenNonce = claims.getStringClaim("nonce");
if (tokenNonce == null || !tokenNonce.equals(nonce)) {
throw new IOException("Invalid OIDC nonce in ID token");
}
UserId userId = userProvisioner.resolveOrProvisionUser(claims, config.getUsernameClaim());
session.removeAttribute(SESSION_STATE);
session.removeAttribute(SESSION_NONCE);
session.removeAttribute(SESSION_REDIRECT_URI);
logger.info("OIDC login successful for WebProtege user {}", userId.getUserName());
return userId;
}

@Nonnull
private static String randomUrlSafe(int numBytes) {
byte[] b = new byte[numBytes];
RANDOM.nextBytes(b);
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
}
}
Loading