Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 1085a43d authored by Nicolas Gelot's avatar Nicolas Gelot
Browse files

feat: add oidc login

parent 0f9b3f2c
Loading
Loading
Loading
Loading
Loading
+43 −1
Original line number Diff line number Diff line
@@ -43,7 +43,7 @@ ONLYOFFICE_DB_PORT=5432
ONLYOFFICE_DB_NAME=onlyoffice
ONLYOFFICE_DB_USER=onlyoffice
ONLYOFFICE_DB_PASSWORD=123456
ONLYOFFICE_DOCUMENT_SERVER_URL=http://localhost:8081  # Internal Docker URL (auto-adjusts to https in staging/prod via env)
ONLYOFFICE_DOCUMENT_SERVER_URL=http://localhost:8081/  # Internal Docker URL (auto-adjusts to https in staging/prod via env)
ONLYOFFICE_DOCUMENT_SERVER_INTERNAL_URL=http://documentserver/
ONLYOFFICE_STORAGE_URL=http://nginx/
ONLYOFFICE_JWT_SECRET=01c48da78419982ff70fe3f1979f9df54fcb4cc954a638dab7cf98d9da09c7ae # $(openssl rand -hex 32)  # Generate: openssl rand -hex 32
@@ -71,3 +71,45 @@ OBJECTSTORE_S3_USEPATH_STYLE=true

OBJECTSTORE_S3_AUTOCREATE=
OBJECTSTORE_S3_OBJECT_PREFIX=

# Keycloak (dev stack)
KEYCLOAK_HOSTNAME=localhost
KEYCLOAK_ADMIN_USER=admin
KEYCLOAK_ADMIN_PASSWORD=@dm1n
KEYCLOAK_DB_HOST=db
KEYCLOAK_DB_NAME=keycloak
KEYCLOAK_DB_USER=keycloak
KEYCLOAK_DB_PASSWORD=123456
KEYCLOAK_TENANT_REALM=mw_instance1
KEYCLOAK_THEME_NAME=murena
KEYCLOAK_NEXTCLOUD_CLIENT_ID=nextcloud
KEYCLOAK_NEXTCLOUD_CLIENT_SECRET=nextcloud-secret
KEYCLOAK_NEXTCLOUD_REDIRECT_BASE=http://localhost:8000

# OIDC app (oidc_login)
OIDC_PROVIDER_URL=http://keycloak:8080/realms/mw_instance1
OIDC_CLIENT_ID=nextcloud
OIDC_CLIENT_SECRET=nextcloud-secret
OIDC_SCOPE=openid profile email
OIDC_UNIQUE_ID_CLAIM=preferred_username
OIDC_AUTO_REDIRECT=true
OIDC_HIDE_LOGIN_FORM=true
OIDC_HIDE_PASSWORD_FORM=true
OIDC_CREATE_GROUPS=true
OIDC_REDIR_FALLBACK=true
OIDC_END_SESSION_REDIRECT=true
OIDC_LOGOUT_URL=http://localhost:8000/login
OIDC_USE_ID_TOKEN=false
OIDC_CODE_CHALLENGE_METHOD=
OIDC_ATTRIBUTES_JSON={"id":"preferred_username","groups":"groups","login_filter":"groups"}
OIDC_LOGIN_FILTER_ALLOWED_VALUES_JSON=["tenant_users","admin"]
OIDC_DISABLE_REGISTRATION=false
# Optional overrides (useful for local dev split front/back channel)
# Front-channel (browser)
OIDC_AUTHORIZATION_ENDPOINT=http://localhost:8080/realms/mw_instance1/protocol/openid-connect/auth
OIDC_ISSUER=http://localhost:8080/realms/mw_instance1
# Back-channel (server to server)
OIDC_TOKEN_ENDPOINT=http://keycloak:8080/realms/mw_instance1/protocol/openid-connect/token
OIDC_USERINFO_ENDPOINT=http://keycloak:8080/realms/mw_instance1/protocol/openid-connect/userinfo
OIDC_JWKS_URI=http://keycloak:8080/realms/mw_instance1/protocol/openid-connect/certs
OIDC_END_SESSION_ENDPOINT=http://localhost:8080/realms/mw_instance1/protocol/openid-connect/logout
+11 −0
Original line number Diff line number Diff line
@@ -53,6 +53,17 @@ Use this mode when integrating or patching apps, adapting entrypoints, or testin
- Need more juice? hook the stack to managed databases or caches by updating the relevant env variables, no rebuild required.
- Object storage ready: set the S3-compatible env vars (AWS S3, MinIO, etc.) to offload files without touching the image.
- Syslog and Sentry endpoints are prewired—just tweak the env vars if you need to point them somewhere else.
- Optional patches and helper scripts can be switched on/off with the same env-driven approach—no Dockerfile edits required.

## Local Keycloak SSO

- `docker-compose.local.yml` also spins up Keycloak + Postgres on http://localhost:8383 so you can test the `oidc_login` app end-to-end.
- Default realm/client settings live in `.env` (`KEYCLOAK_*` variables); tweak them before running `docker compose up`.
- The helper service `keycloak-init` executes `config/keycloak/init.sh`, which auto-creates the realm and the Nextcloud confidential client (redirect URIs, secret, web origins).
- Once the bootstrap succeeds, the init script drops a sentinel file inside the shared Keycloak volume so subsequent restarts skip the provisioning step automatically (delete it if you need to re-run the init).
- `hooks.d/before-starting/20-configure-oidc.sh` reads the `OIDC_*` env vars at every boot and configures the `oidc_login` app via `occ`, so updating `.env` is all you need to re-point Nextcloud.
- Enable the plugin with `docker compose -f docker-compose.local.yml exec nextcloud su -s /bin/sh www-data -c "php occ app:enable oidc_login"` if it isn’t already active.
- Set `OIDC_AUTO_REDIRECT=1` and `OIDC_HIDE_LOGIN_FORM=1` in `.env` if you want Nextcloud to immediately redirect users to Keycloak instead of showing the legacy password screen.

## Coding conventions

+243 −0
Original line number Diff line number Diff line
#!/bin/sh
set -euo pipefail

KEYCLOAK_URL="${KEYCLOAK_URL:-http://keycloak:8080}"
TENANT_REALM="${KEYCLOAK_TENANT_REALM:-mw_instance1}"
REALM="${TENANT_REALM}"
NEXTCLOUD_CLIENT_ID="${KEYCLOAK_NEXTCLOUD_CLIENT_ID:-nextcloud}"
NEXTCLOUD_CLIENT_SECRET="${KEYCLOAK_NEXTCLOUD_CLIENT_SECRET:-nextcloud-secret}"
REDIRECT_BASE="${KEYCLOAK_NEXTCLOUD_REDIRECT_BASE:-http://localhost:8000}"
THEME_NAME="${KEYCLOAK_THEME_NAME:-}"
NC_ADMIN_USER="${NEXTCLOUD_ADMIN_USER:-}"
NC_ADMIN_PASSWORD="${NEXTCLOUD_ADMIN_PASSWORD:-}"
SENTINEL_PATH="${KEYCLOAK_SENTINEL_PATH:-/opt/keycloak/data/.murena-oidc-init}"
ADMIN_USER="${KEYCLOAK_ADMIN_USER:?Missing KEYCLOAK_ADMIN_USER}"
ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:?Missing KEYCLOAK_ADMIN_PASSWORD}"

KCADM="/opt/keycloak/bin/kcadm.sh"

if [ -f "${SENTINEL_PATH}" ]; then
  echo "[keycloak-init] Sentinel found (${SENTINEL_PATH}). Keycloak already configured. Exiting."
  exit 0
fi

echo "[keycloak-init] Waiting for Keycloak at ${KEYCLOAK_URL}..."
until "${KCADM}" config credentials --server "${KEYCLOAK_URL}" --realm master --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}" >/dev/null 2>&1; do
  printf '.'
  sleep 5
done
printf "\n[keycloak-init] Connected to Keycloak.\n"

ensure_realm() {
  realm_name="$1"
  if ! "${KCADM}" get "realms/${realm_name}" >/dev/null 2>&1; then
    echo "[keycloak-init] Creating realm ${realm_name}."
    "${KCADM}" create realms -s "realm=${realm_name}" -s enabled=true -s registrationAllowed=false
  else
    echo "[keycloak-init] Realm ${realm_name} already exists."
  fi
}

ensure_realm "${TENANT_REALM}"

fetch_client_uuid() {
  "${KCADM}" get clients -r "${REALM}" -q "clientId=${CLIENT_ID}" --fields id,clientId |
    sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}

fetch_user_id() {
  "${KCADM}" get users -r "${REALM}" -q "username=$1" --fields id,username |
    sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}

fetch_mapper_id() {
  client_uuid="$1"
  mapper_name="$2"
  "${KCADM}" get "clients/${client_uuid}/protocol-mappers/models" -r "${REALM}" -q "name=${mapper_name}" --fields id,name |
    sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}

ensure_group_mapper() {
  client_uuid="$1"
  mapper_name="$2"
  mapper_id="$(fetch_mapper_id "${client_uuid}" "${mapper_name}")"
  if [ -n "${mapper_id}" ]; then
    echo "[keycloak-init] Removing existing protocol mapper ${mapper_name}."
    "${KCADM}" delete "clients/${client_uuid}/protocol-mappers/models/${mapper_id}" -r "${REALM}"
  fi
  echo "[keycloak-init] Creating protocol mapper ${mapper_name}."
  "${KCADM}" create "clients/${client_uuid}/protocol-mappers/models" -r "${REALM}" \
    -s "name=${mapper_name}" \
    -s "protocol=openid-connect" \
    -s "protocolMapper=oidc-group-membership-mapper" \
    -s "config.\"userinfo.token.claim\"=true" \
    -s "config.\"id.token.claim\"=true" \
    -s "config.\"access.token.claim\"=true" \
    -s "config.\"claim.name\"=groups" \
    -s "config.\"full.path\"=false"
}

ensure_user() {
  username="$1"
  password="$2"
  user_id="$(fetch_user_id "${username}")"
  if [ -z "${user_id}" ]; then
    echo "[keycloak-init] Creating user ${username}."
    "${KCADM}" create users -r "${REALM}" \
      -s "username=${username}" \
      -s enabled=true
    user_id="$(fetch_user_id "${username}")"
  fi
  if [ -n "${user_id}" ]; then
    "${KCADM}" set-password -r "${REALM}" --username "${username}" --new-password "${password}" --temporary=false
  fi
}

fetch_group_id() {
  group_name="$1"
  "${KCADM}" get groups -r "${REALM}" -q "search=${group_name}" --fields id,name |
    sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}

ensure_group() {
  group_name="$1"
  group_id="$(fetch_group_id "${group_name}")"
  if [ -z "${group_id}" ]; then
    echo "[keycloak-init] Creating group ${group_name}." >&2
    "${KCADM}" create groups -r "${REALM}" -s "name=${group_name}"
    group_id="$(fetch_group_id "${group_name}")"
  fi
  printf '%s' "${group_id}"
}

add_user_to_group() {
  user_id="$1"
  group_id="$2"
  if [ -n "${user_id}" ] && [ -n "${group_id}" ]; then
    "${KCADM}" update "users/${user_id}/groups/${group_id}" -r "${REALM}"
  fi
}

create_client() {
  realm_name="$1"
  client_id="$2"
  client_secret="$3"
  public_client="${4:-false}"
  standard_flow="${5:-true}"
  direct_grant="${6:-false}"
  service_accounts="${7:-false}"
  base_url="${8:-}"
  redirect_uris="${9:-[]}"
  web_origins="${10:-[]}"
  REALM="${realm_name}"
  CLIENT_ID="${client_id}"
  CLIENT_UUID="$(fetch_client_uuid)"
  if [ -z "${CLIENT_UUID}" ]; then
    echo "[keycloak-init] Creating client ${CLIENT_ID}." >&2
    "${KCADM}" create clients -r "${REALM}" \
      -s "clientId=${CLIENT_ID}" \
      -s "name=client_${CLIENT_ID}" \
      -s enabled=true \
      -s protocol=openid-connect \
      -s publicClient="${public_client}" \
      -s standardFlowEnabled="${standard_flow}" \
      -s directAccessGrantsEnabled="${direct_grant}" \
      -s serviceAccountsEnabled="${service_accounts}" \
      -s "secret=${client_secret}"
    CLIENT_UUID="$(fetch_client_uuid)"
  else
    echo "[keycloak-init] Client ${CLIENT_ID} already exists, updating." >&2
  fi

  if [ -z "${CLIENT_UUID}" ]; then
    echo "[keycloak-init] ERROR: unable to determine client UUID for ${CLIENT_ID}." >&2
    exit 1
  fi

  "${KCADM}" update "clients/${CLIENT_UUID}" -r "${REALM}" \
    -s "name=client_${CLIENT_ID}" \
    -s "secret=${client_secret}" \
    -s "publicClient=${public_client}" \
    -s "standardFlowEnabled=${standard_flow}" \
    -s "directAccessGrantsEnabled=${direct_grant}" \
    -s "serviceAccountsEnabled=${service_accounts}" \
    -s "redirectUris=${redirect_uris}" \
    -s "webOrigins=${web_origins}"

  if [ -n "${base_url}" ]; then
    "${KCADM}" update "clients/${CLIENT_UUID}" -r "${REALM}" \
      -s "baseUrl=${base_url}" \
      -s "attributes.\"post.logout.redirect.uris\"=${base_url}/*"
  fi

  printf '%s' "${CLIENT_UUID}"
}

configure_realm_defaults() {
  realm_name="$1"
  REALM="${realm_name}"
  if [ -n "${THEME_NAME}" ]; then
    echo "[keycloak-init] Setting realm themes to ${THEME_NAME}."
    "${KCADM}" update "realms/${REALM}" \
      -s "loginTheme=${THEME_NAME}" \
      -s "accountTheme=${THEME_NAME}" \
      -s "emailTheme=${THEME_NAME}"
  fi

  echo "[keycloak-init] Configuring realm login options and locales."
  SUPPORTED_LOCALES='["de","en","fr","it","es","nl","pt"]'
  "${KCADM}" update "realms/${REALM}" \
    -s "resetPasswordAllowed=true" \
    -s "rememberMe=true" \
    -s "internationalizationEnabled=true" \
    -s "supportedLocales=${SUPPORTED_LOCALES}" \
    -s "defaultLocale=en"
}

# Single realm setup for Nextcloud
REALM="${TENANT_REALM}"
configure_realm_defaults "${TENANT_REALM}"

TENANT_CLIENT_UUID="$(create_client "${TENANT_REALM}" "${NEXTCLOUD_CLIENT_ID}" "${NEXTCLOUD_CLIENT_SECRET}" "false" "true" "false" "false" "${REDIRECT_BASE%/}" "[\"${REDIRECT_BASE%/}/apps/oidc_login/*\",\"${REDIRECT_BASE%/}/index.php/apps/oidc_login/*\"]" "[\"${REDIRECT_BASE%/}\"]")"

# Ensure default browser flow (no broker)
if "${KCADM}" get "authentication/flows/broker-redirect" -r "${TENANT_REALM}" >/dev/null 2>&1; then
  "${KCADM}" delete "authentication/flows/broker-redirect" -r "${TENANT_REALM}"
fi
echo "[keycloak-init] Setting browser flow to browser in realm ${TENANT_REALM}."
"${KCADM}" update "realms/${TENANT_REALM}" -s "browserFlow=browser"

# Admin user in tenant realm
if [ -n "${NC_ADMIN_USER}" ] && [ -n "${NC_ADMIN_PASSWORD}" ]; then
  ensure_user "${NC_ADMIN_USER}" "${NC_ADMIN_PASSWORD}"
  admin_user_id="$(fetch_user_id "${NC_ADMIN_USER}")"
  if [ -n "${admin_user_id}" ]; then
    admin_group_id="$(ensure_group "admin")"
    add_user_to_group "${admin_user_id}" "${admin_group_id}"
    realm_mgmt_id="$("${KCADM}" get clients -r "${REALM}" -q "clientId=realm-management" --fields id,clientId | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
    if [ -n "${realm_mgmt_id}" ]; then
      "${KCADM}" add-roles -r "${REALM}" --uid "${admin_user_id}" --cclientid realm-management --rolename realm-admin
    fi
  fi
fi

# Create users in tenant realm
for i in 01 02 03 04 05 06 07 08 09 10; do
  ensure_user "user${i}" "admin"
done

tenant_users_group_id="$(ensure_group "tenant_users")"
for i in 01 02 03 04 05 06 07 08 09 10; do
  user_id="$(fetch_user_id "user${i}")"
  add_user_to_group "${user_id}" "${tenant_users_group_id}"
done

# Include realm roles in tenant tokens
CLIENT_ID="${NEXTCLOUD_CLIENT_ID}"
CLIENT_UUID="${TENANT_CLIENT_UUID}"
ensure_group_mapper "${CLIENT_UUID}" "groups_claim"


mkdir -p "$(dirname "${SENTINEL_PATH}")"
touch "${SENTINEL_PATH}"
echo "[keycloak-init] Wrote sentinel ${SENTINEL_PATH} to skip future runs."
+13 −0
Original line number Diff line number Diff line
@@ -9,6 +9,12 @@ map $http_x_forwarded_proto $real_scheme {
  ''      $scheme;
}

map $http_cookie $nc_has_session {
    default 0;
    "~*nc_session_id=" 1;
    "~*oc_sessionPassphrase=" 1;
}

# use docker DNS resolver with limited cache value for nc update or scaling
resolver 127.0.0.11 valid=5s;

@@ -173,6 +179,13 @@ server {
        access_log off;     # Optional: Don't log access to assets
    }

    location = /logout {
        if ($nc_has_session = 0) {
            return 302 $real_scheme://$http_host/login?noredir=1;
        }
        try_files $uri /index.php$request_uri;
    }

    # Rule borrowed from `.htaccess`
    location /remote {
        return 301 $real_scheme://$http_host/remote.php$request_uri;
+26 −0
Original line number Diff line number Diff line
#!/bin/bash
set -euo pipefail

run_psql() {
  psql -v ON_ERROR_STOP=1 -U "${POSTGRES_USER}" "$@"
}

echo "Starting Keycloak DB init..."

if ! run_psql -tAc "SELECT 1 FROM pg_roles WHERE rolname = '${KEYCLOAK_DB_USER}'" | grep -q 1; then
  run_psql -c "CREATE USER ${KEYCLOAK_DB_USER} WITH PASSWORD '${KEYCLOAK_DB_PASSWORD}';"
  echo "Created user '${KEYCLOAK_DB_USER}'."
else
  echo "User '${KEYCLOAK_DB_USER}' already exists."
fi

if ! run_psql -tAc "SELECT 1 FROM pg_database WHERE datname = '${KEYCLOAK_DB_NAME}'" | grep -q 1; then
  run_psql -c "CREATE DATABASE ${KEYCLOAK_DB_NAME} OWNER ${KEYCLOAK_DB_USER};"
  echo "Created DB '${KEYCLOAK_DB_NAME}'."
else
  echo "DB '${KEYCLOAK_DB_NAME}' already exists."
fi

run_psql -c "GRANT ALL PRIVILEGES ON DATABASE ${KEYCLOAK_DB_NAME} TO ${KEYCLOAK_DB_USER};"

echo "Keycloak DB and user initialized successfully."
Loading