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
25 changes: 25 additions & 0 deletions config/keycloak/00-wait-for-keycloak.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh
# Wait for Keycloak to accept admin credentials via kcadm.sh.
# Exits 0 when ready, 1 on timeout.
#
# Usage: docker exec keycloak /bin/sh /opt/keycloak/bin/00-wait-for-keycloak.sh

KCADM="/opt/keycloak/bin/kcadm.sh"
MAX_WAIT="${KC_MAX_WAIT:-120}"

echo "[wait-for-kc] Waiting for Keycloak..."
elapsed=0
while [ $elapsed -lt $MAX_WAIT ]; do
if $KCADM config credentials \
--server http://localhost:8080 --realm master \
--user "${KEYCLOAK_ADMIN:-kcadmin}" \
--password "${KEYCLOAK_ADMIN_PASSWORD:-admin}" >/dev/null 2>&1; then
echo "[wait-for-kc] Ready (${elapsed}s)"
exit 0
fi
sleep 2
elapsed=$((elapsed + 2))
done

echo "[wait-for-kc] Not ready after ${MAX_WAIT}s"
exit 1
44 changes: 44 additions & 0 deletions config/keycloak/10-import-clients.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/bin/sh
# Import Keycloak client definitions from /opt/keycloak/data/clients/*.json
# via kcadm.sh partialImport. Existing clients are skipped (idempotent).
#
# Requires: 00-wait-for-keycloak.sh ran first (kcadm.sh authenticated).
# Usage: docker exec keycloak /bin/sh /opt/keycloak/bin/10-import-clients.sh

set -eu

KCADM="/opt/keycloak/bin/kcadm.sh"
REALM="${KC_REALM_NAME:-openCloud}"
CLIENTS_DIR="/opt/keycloak/data/clients"
OC_URL="https://${OC_DOMAIN:-cloud.opencloud.test}"

if [ ! -d "$CLIENTS_DIR" ] || ! ls "$CLIENTS_DIR"/*.json >/dev/null 2>&1; then
echo "[import-clients] No client files found — skipping"
exit 0
fi

for client_file in "$CLIENTS_DIR"/*.json; do
[ -f "$client_file" ] || continue
client_name=$(basename "$client_file" .json)
tmp_file=$(mktemp)

# Keycloak's --import-realm resolves {{VAR}} from env vars.
# partialImport does not — we replicate this for {{OC_URL}}.
sed "s|{{OC_URL}}|${OC_URL}|g" "$client_file" > "$tmp_file"

# Wrap in partialImport payload (SKIP existing)
tmp_payload=$(mktemp)
printf '{"ifResourceExists":"SKIP","clients":[' > "$tmp_payload"
cat "$tmp_file" >> "$tmp_payload"
printf ']}' >> "$tmp_payload"

if $KCADM create partialImport -r "$REALM" -f "$tmp_payload" >/dev/null 2>&1; then
echo "[import-clients] $client_name"
else
echo "[import-clients] $client_name — failed" >&2
fi

rm -f "$tmp_file" "$tmp_payload"
done

echo "[import-clients] Done"
56 changes: 56 additions & 0 deletions config/keycloak/11-assign-client-scopes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/sh
# Assign defaultClientScopes to imported Keycloak clients.
#
# Workaround: partialImport ignores defaultClientScopes from client JSONs.
# https://github.com/keycloak/keycloak/issues/16289
# This script reads scope names from .scopes sidecar files and assigns
# them via kcadm.sh. Can be removed when Keycloak fixes the issue.
#
# Scopes not present in the realm are silently skipped (e.g.
# OpenCloudUnique_ID only exists in the LDAP realm variant).
#
# Requires: 00-wait-for-keycloak.sh ran first (kcadm.sh authenticated).
# Usage: docker exec keycloak /bin/sh /opt/keycloak/bin/11-assign-client-scopes.sh

set -eu

KCADM="/opt/keycloak/bin/kcadm.sh"
REALM="${KC_REALM_NAME:-openCloud}"
CLIENTS_DIR="/opt/keycloak/data/clients"

if [ ! -d "$CLIENTS_DIR" ] || ! ls "$CLIENTS_DIR"/*.scopes >/dev/null 2>&1; then
echo "[assign-scopes] No .scopes files found — skipping"
exit 0
fi

# Cache all scope IDs once (avoid repeated API calls)
all_scopes=$($KCADM get client-scopes -r "$REALM" --fields id,name 2>/dev/null || true)

for scopes_file in "$CLIENTS_DIR"/*.scopes; do
[ -f "$scopes_file" ] || continue
client_name=$(basename "$scopes_file" .scopes)

client_id=$($KCADM get clients -r "$REALM" -q "clientId=$client_name" --fields id 2>/dev/null \
| grep -o '[0-9a-f-]\{36\}' | head -1 || true)
if [ -z "$client_id" ]; then
echo "[assign-scopes] $client_name: client not found — skipping"
continue
fi

assigned=""
skipped=""
for scope_name in $(tr ',' ' ' < "$scopes_file"); do
scope_id=$(echo "$all_scopes" | grep -A1 "\"$scope_name\"" | grep '"id"' \
| grep -o '[0-9a-f-]\{36\}' | head -1)
if [ -n "$scope_id" ]; then
$KCADM update "clients/$client_id/default-client-scopes/$scope_id" \
-r "$REALM" >/dev/null 2>&1 || true
assigned="$assigned $scope_name"
else
skipped="$skipped $scope_name"
fi
done
echo "[assign-scopes] $client_name:$assigned${skipped:+ (skipped:$skipped)}"
done

echo "[assign-scopes] Done"
82 changes: 82 additions & 0 deletions config/keycloak/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Keycloak Configuration

## Realm Import

On first start, Keycloak imports the realm from one of:

- `opencloud-realm.dist.json` — LDAP/Keycloak shared user directory
- `opencloud-realm-autoprovisioning.dist.json` — auto-provisioning (demo/testing)

The entrypoint replaces `cloud.opencloud.test` with `$OC_DOMAIN` before import.

## Modular Client Definitions

Clients are individual JSON files in `clients/`, imported via a post-start pipeline:

```
clients/
├── web.json + web.scopes
├── OpenCloudAndroid.json + .scopes
├── OpenCloudDesktop.json + .scopes
├── OpenCloudIOS.json + .scopes
└── cyberduck.json + .scopes
```

**Post-start pipeline** (runs in background after Keycloak starts):

| Step | Script | What it does |
|------|--------|-------------|
| 0 | `00-wait-for-keycloak.sh` | Wait for Keycloak, authenticate kcadm.sh |
| 1 | `10-import-clients.sh` | `partialImport` each `clients/*.json` (SKIP existing) |
| 2 | `11-assign-client-scopes.sh` | Assign scopes from `*.scopes` sidecars |

**Adding a client:** drop a `.json` + `.scopes` file in `clients/`, restart Keycloak.

### Why `.scopes` sidecar files?

Keycloak's `partialImport` ignores `defaultClientScopes` from client JSONs
([keycloak#16289](https://github.com/keycloak/keycloak/issues/16289)).
The `.scopes` file works around this — one line, comma-separated scope names:

```
web-origins,profile,roles,groups,basic,email,OpenCloudUnique_ID
```

Scopes that don't exist in the realm are skipped (e.g. `OpenCloudUnique_ID`
only exists in the LDAP variant). When Keycloak fixes #16289, the `.scopes`
files and step 2 can be removed.

## Custom Clients

To add your own clients, mount a directory via Compose override — don't modify this repo:

```yaml
# custom/keycloak-extra-clients.yml
services:
keycloak:
volumes:
- "./my-clients:/opt/keycloak/data/clients-custom:ro"
```

Add to `COMPOSE_FILE` and extend the pipeline to scan the additional path.

## Validation

Proves the modular approach produces an identical realm compared to the monolith:

```bash
bash config/keycloak/validate-modular-clients.sh
```

Requires: docker, jq. Starts throwaway containers, compares clients, scopes, roles,
groups, and realm settings for both LDAP and autoprovisioning variants.

## Debugging

Each pipeline step can be run standalone:

```bash
docker exec keycloak /bin/sh /opt/keycloak/bin/00-wait-for-keycloak.sh
docker exec keycloak /bin/sh /opt/keycloak/bin/10-import-clients.sh
docker exec keycloak /bin/sh /opt/keycloak/bin/11-assign-client-scopes.sh
```
3 changes: 2 additions & 1 deletion config/keycloak/clients/OpenCloudAndroid.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"roles",
"groups",
"basic",
"email"
"email",
"OpenCloudUnique_ID"
],
"optionalClientScopes": [
"address",
Expand Down
1 change: 1 addition & 0 deletions config/keycloak/clients/OpenCloudAndroid.scopes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web-origins,profile,roles,groups,basic,email,OpenCloudUnique_ID
3 changes: 2 additions & 1 deletion config/keycloak/clients/OpenCloudDesktop.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"roles",
"groups",
"basic",
"email"
"email",
"OpenCloudUnique_ID"
],
"optionalClientScopes": [
"address",
Expand Down
1 change: 1 addition & 0 deletions config/keycloak/clients/OpenCloudDesktop.scopes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web-origins,profile,roles,groups,basic,email,OpenCloudUnique_ID
3 changes: 2 additions & 1 deletion config/keycloak/clients/OpenCloudIOS.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
"roles",
"groups",
"basic",
"email"
"email",
"OpenCloudUnique_ID"
],
"optionalClientScopes": [
"address",
Expand Down
1 change: 1 addition & 0 deletions config/keycloak/clients/OpenCloudIOS.scopes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web-origins,profile,roles,groups,basic,email,OpenCloudUnique_ID
1 change: 1 addition & 0 deletions config/keycloak/clients/cyberduck.scopes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web-origins,profile,roles,groups,basic,email
3 changes: 2 additions & 1 deletion config/keycloak/clients/web.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"roles",
"groups",
"basic",
"email"
"email",
"OpenCloudUnique_ID"
],
"optionalClientScopes": [
"address",
Expand Down
1 change: 1 addition & 0 deletions config/keycloak/clients/web.scopes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web-origins,profile,roles,groups,basic,email,OpenCloudUnique_ID
15 changes: 14 additions & 1 deletion config/keycloak/docker-entrypoint-override.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@ log_level=$(printf '%s' "$KC_LOG_LEVEL" | tr '[:upper:]' '[:lower:]')
case "$log_level" in trace|debug) printenv ;; *) ;; esac

# replace openCloud domain and LDAP password in keycloak realm import
mkdir /opt/keycloak/data/import
mkdir -p /opt/keycloak/data/import
sed -e "s/cloud.opencloud.test/${OC_DOMAIN}/g" -e "s/ldap-admin-password/${LDAP_ADMIN_PASSWORD:-admin}/g" /opt/keycloak/data/import-dist/openCloud-realm.json > /opt/keycloak/data/import/openCloud-realm.json

# Post-start pipeline (background): import modular client definitions.
# Each step is standalone and can be run manually for debugging:
# docker exec keycloak /bin/sh /opt/keycloak/bin/10-import-clients.sh
(
if ! /bin/sh /opt/keycloak/bin/00-wait-for-keycloak.sh; then
echo "[post-start] Keycloak not ready — skipping client import"
exit 0
fi
/bin/sh /opt/keycloak/bin/10-import-clients.sh
/bin/sh /opt/keycloak/bin/11-assign-client-scopes.sh
echo "[post-start] Done"
) &

# run original docker-entrypoint
/opt/keycloak/bin/kc.sh "$@"
Loading