diff --git a/.env.sample b/.env.sample index a2788bc..d325e6f 100644 --- a/.env.sample +++ b/.env.sample @@ -28,3 +28,24 @@ SECRET_HQL_API_VERSION=v1 #SMTP_USERNAME= #SECRET_SMTP_PASSWORD_VERSION=v1 #SMTP_STARTTLS_ONLY=true + +# Bootstrap admin account (only on first deploy) +# Generate admin bootstrap hash: abra app cmd --local generate_bootstrap_admin_password +#COMPOSE_FILE="$COMPOSE_FILE:compose.bootstrapadmin.yml" +#SECRET_ADMIN_PWHASH_VERSION=v1 # generate=false + +# API key +# When set before first deploy, rauthy will bootstrap an API key with the Base64 encoded JSON access rights. +# After first deploy, api_secret is also used by abra.sh functions (create_clients, create_groups) +#COMPOSE_FILE="$COMPOSE_FILE:compose.api.yml" +#SECRET_API_SECRET_VERSION=v1 # length=64 + +# Default value for API_BASE64_ACCESS (ewog...) is read and create rights on Clients, Roles and Groups. See file api.key.example.json and https://sebadob.github.io/rauthy/config/bootstrap.html#api-key +# In rauthy this value is stored in the config as API_KEY +#API_BASE64_ACCESS_RIGHTS="ewogICJuYW1lIjoiYm9vdHN0cmFwIiwKICAiZXhwIjpudWxsLAogICJhY2Nlc3MiOlt7CiAgICAgICJncm91cCI6IkNsaWVudHMiLAogICAgICAiYWNjZXNzX3JpZ2h0cyI6WyJyZWFkIiwiY3JlYXRlIl0KICAgIH0sewogICAgICAiZ3JvdXAiOiJSb2xlcyIsCiAgICAgICJhY2Nlc3NfcmlnaHRzIjpbInJlYWQiLCJjcmVhdGUiXQogICAgfSx7CiAgICAgICJncm91cCI6ICJHcm91cHMiLAogICAgICAiYWNjZXNzX3JpZ2h0cyI6WyJyZWFkIiwiY3JlYXRlIl0KICAgIH0sewogICAgICAiZ3JvdXAiOiAiU2VjcmV0cyIsCiAgICAgICJhY2Nlc3NfcmlnaHRzIjpbInJlYWQiXQogICAgfQogIF0KfQo=" + +# Nextcloud OIDC integration +#COMPOSE_FILE="$COMPOSE_FILE:compose.nextcloud.yml" +#SECRET_NEXTCLOUD_SEC_VERSION=v1 # length=5 prefix=Empty- This needs a value before deployment, but it will be set to the actual secret after the OIDC client is created. +#NEXTCLOUD_CLIENT_NAME="Nextcloud" +#NEXTCLOUD_REDIRECT_URI="https://nextcloud.example.com/apps/user_oidc/code" diff --git a/README.md b/README.md index 9629d0a..213650a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,86 @@ mistakenly rate limited based on internal ipv4 addresses (e.g. `10.0.0.6`). COMPOSE_FILE="$COMPOSE_FILE:compose.host.yml" ``` +### Bootstrap admin password + +By default, rauthy generates a random admin password and prints it to the logs on first deploy. If you want to set a known password upfront, you can bootstrap it before the first deploy. + +Requires `argon2` on your local machine. + +1. With `abra app config `, configure the following envs: + ``` + COMPOSE_FILE="$COMPOSE_FILE:compose.bootstrapadmin.yml" + SECRET_ADMIN_PWHASH_VERSION=v1 + ``` +2. Generate and insert the admin password hash: + ``` + abra app cmd --local generate_bootstrap_admin_password + ``` +3. Deploy: `abra app deploy ` + +Rauthy will use the bootstrapped hash instead of generating a password. + +### API key + +The API key allows access to the Rauthy API, used for creating OIDC clients, groups, and roles. + +#### Setup + +1. With `abra app config `, configure the following envs: + ``` + COMPOSE_FILE="$COMPOSE_FILE:compose.api.yml" + SECRET_API_SECRET_VERSION=v1 + ``` +2. Generate the secret: + ``` + abra app secret generate api_secret v1 + ``` +3. When `API_BASE64_ACCESS_RIGHTS` and `api_secret` are set before first deployment, Rauthy will bootstrap an API key with the access rights as configured in `API_BASE64_ACCESS_RIGHTS`. The default value in `.env.sample` grants read and create rights on Clients, Roles, and Groups. See the [rauthy bootstrap docs](https://sebadob.github.io/rauthy/config/bootstrap.html#api-key) for the JSON schema. If `API_BASE64_ACCESS_RIGHTS` is empty or set after first deployment, no API key is bootstrapped and you'll need to create one manually in the admin UI with secret `api_secret` to be used by the abra.sh functions. + +#### Available commands + +All commands require the API key to be set up and the app to be running. + +**`create_client [insertsecret]`** — Creates a confidential OIDC client. Reads configuration from env vars prefixed with the uppercased client ID: + +| Variable | Required | Default | +|---|---|---| +| `_CLIENT_NAME` | yes | — | +| `_REDIRECT_URI` | yes | — | +| `_ALLOWED_SCOPES` | no | `email openid profile groups` | + +Without `insertsecret`, prints the generated client secret. With `insertsecret`, it inserts Rauthy's client secret in the app secret `_sec` (undeploying and redeploying the app automatically). + +**`create_groups [ ...]`** — Creates one or more groups. + +**`create_roles [ ...]`** — Creates one or more roles. + +#### Example: Nextcloud OIDC integration + +This sets up rauthy as an OIDC provider for a Nextcloud app. Requires the API key to be set up first. + +1. With `abra app config `, configure the following envs: + ``` + COMPOSE_FILE="$COMPOSE_FILE:compose.nextcloud.yml" + SECRET_NEXTCLOUD_SEC_VERSION=v1 + NEXTCLOUD_CLIENT_NAME="Nextcloud" + NEXTCLOUD_REDIRECT_URI="https://nextcloud.example.com/apps/user_oidc/code" + ``` +2. Generate a placeholder secret (required before deploy; it will be replaced after client creation): + ``` + abra app secret generate nextcloud_sec v1 + ``` +3. Deploy: `abra app deploy ` +4. Create the OIDC client in rauthy and insert the generated client secret: + ``` + abra app cmd create_client nextcloud insertsecret + ``` + This undeploys the app, replaces the `nextcloud_sec` Docker secret with the real client secret, and redeploys. +5. Configure Nextcloud's OIDC provider (via the `user_oidc` app, see [Nextcloud user_oidc docs](https://git.coopcloud.tech/coop-cloud/nextcloud#how-do-i-enable-openid-connect-oidc-providers)) with: + - **Discovery endpoint**: `https:///.well-known/openid-configuration` + - **Client ID**: `nextcloud` + - **Client secret**: the value inserted above (can also be view in Rauthy Admin UI) + ### Encryption key rotation This recipe supports encryption key rotation as described in [the docs](https://sebadob.github.io/rauthy/config/encryption.html). To rotate keys the first time: diff --git a/abra.sh b/abra.sh index bfc64ee..5b8e12a 100644 --- a/abra.sh +++ b/abra.sh @@ -1,6 +1,26 @@ set -e -export CONFIG_TOML_VERSION=v4 +export CONFIG_TOML_VERSION=v5 + +generate_bootstrap_admin_password() { + if ! command -v argon2 &> /dev/null; then + echo "ERROR: 'argon2' CLI not found. Install it (e.g. 'apt install argon2')" + exit 1 + fi + + PASSWORD="$(openssl rand -base64 24)" + SALT="$(openssl rand -base64 24)" + HASH="$(echo -n "$PASSWORD" | argon2 "$SALT" -id -t 3 -m 16 -p 2 -l 32 -e)" + + if abra app secret insert -C "$APP_NAME" admin_pwhash v1 "$HASH"; then + echo "Generated admin password:" + echo "$PASSWORD" + echo "WARNING: password is NOT shown again, please save it NOW" + else + echo "Failed to insert admin hash." + exit 1 + fi +} generate_enc_keys() { KEY_A="$(openssl rand -base64 32)" @@ -11,3 +31,213 @@ generate_enc_keys() { echo " enc_keys_a $KEY_A" echo " enc_keys_b $KEY_B" } + +# Reads a Docker Swarm secret value from the running container +# Requires jq locally and SSH access to the server. +# Usage: get_secret +get_secret() { + local SECRET_NAME="$1" + + if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required. Install with: apt install jq" >&2 + exit 1 + fi + + local SERVER + SERVER=$(abra app ls -m | jq -r --arg domain "$APP_NAME" '[.[].apps[] | select(.domain == $domain) | .server] | first' 2>/dev/null) + + if [ -z "$SERVER" ] || [ "$SERVER" = "null" ]; then + echo "ERROR: could not determine server for app '$APP_NAME'" >&2 + exit 1 + fi + + local MATCH + MATCH=$(ssh "$SERVER" " + docker stack services ${STACK_NAME} --format '{{.Name}}' | while read svc; do + CID=\$(docker ps --no-trunc -q --filter \"name=\${svc}\" | head -1) + [ -z \"\$CID\" ] && continue + docker service inspect \"\$svc\" --format '{{json .Spec.TaskTemplate.ContainerSpec.Secrets}}' | jq -r --arg cid \"\$CID\" '.[]? | .SecretID + \" \" + \$cid + \" \" + .SecretName' + done + " 2>/dev/null | grep " ${STACK_NAME}_${SECRET_NAME}_" | head -1) + + if [ -z "$MATCH" ]; then + echo "ERROR: secret '$SECRET_NAME' not found in stack '$STACK_NAME'" >&2 + exit 1 + fi + + local SECRET_ID CID + SECRET_ID=$(echo "$MATCH" | awk '{print $1}') + CID=$(echo "$MATCH" | awk '{print $2}') + + local VALUE + VALUE=$(ssh "$SERVER" "cat /var/lib/docker/containers/${CID}/mounts/secrets/${SECRET_ID} 2>/dev/null || sudo cat /var/lib/docker/containers/${CID}/mounts/secrets/${SECRET_ID} 2>/dev/null") + + if [ -z "$VALUE" ]; then + echo "ERROR: could not read value for secret '$SECRET_NAME'" >&2 + exit 1 + fi + + printf '%s' "$VALUE" +} + +# Usage: rauthy_api_request [json_body] +# Sets globals API_HTTP_STATUS and API_BODY. +rauthy_api_request() { + local METHOD="$1" ENDPOINT="$2" PAYLOAD="${3:-}" + if [ -z "$API_SECRET" ]; then + API_SECRET=$(get_secret api_secret) + fi + local AUTH_HEADER + AUTH_HEADER=$(printf 'Authorization: API-Key bootstrap$%s' "$API_SECRET") + local ARGS=(-s -w "\n%{http_code}" -X "$METHOD" -H "$AUTH_HEADER") + [ -n "$PAYLOAD" ] && ARGS+=(-H "Content-Type: application/json" -d "$PAYLOAD") + local RESPONSE + RESPONSE=$(curl "${ARGS[@]}" "https://${DOMAIN}/auth/v1${ENDPOINT}") + API_HTTP_STATUS=$(echo "$RESPONSE" | tail -1) + API_BODY=$(echo "$RESPONSE" | sed '$d') +} + +# Creates an OIDC client in Rauthy and prints the client secret. +# Usage: create_client [insertsecret] +# Reads config from env vars prefixed with uppercased client_id: +# _CLIENT_NAME (required) +# _REDIRECT_URI (required) +# _ALLOWED_SCOPES (optional, default: 'email openid profile groups') +# With 'insertsecret': undeploys APP_NAME, replaces the Docker secret, then redeploys. +# Example: NEXTCLOUD_CLIENT_NAME="Nextcloud" NEXTCLOUD_REDIRECT_URI="https://..." create_client nextcloud +create_client() { + local CLIENT_ID="$1" + local MODE="$2" + + if [ -z "$CLIENT_ID" ]; then + echo "ERROR: no client_id; Usage: create_client [insertsecret]" >&2 + exit 1 + fi + + local PREFIX + PREFIX=$(echo "$CLIENT_ID" | tr '[:lower:]' '[:upper:]') + + local CLIENT_NAME REDIRECT_URI ALLOWED_SCOPES + CLIENT_NAME=$(eval "echo \"\${${PREFIX}_CLIENT_NAME}\"") + REDIRECT_URI=$(eval "echo \"\${${PREFIX}_REDIRECT_URI}\"") + ALLOWED_SCOPES=$(eval "echo \"\${${PREFIX}_ALLOWED_SCOPES:-email openid profile groups}\"") + + if [ -z "$CLIENT_NAME" ] || [ -z "$REDIRECT_URI" ]; then + echo "ERROR: ${PREFIX}_CLIENT_NAME and ${PREFIX}_REDIRECT_URI must be set" >&2 + exit 1 + fi + + if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required. Install with: apt install jq" >&2 + exit 1 + fi + + rauthy_api_request GET "/clients/${CLIENT_ID}" + if [ "$API_HTTP_STATUS" = "200" ]; then + echo "Client '${CLIENT_ID}' already exists, skipping creation." + else + local PAYLOAD + PAYLOAD=$(jq -n \ + --arg id "$CLIENT_ID" \ + --arg name "$CLIENT_NAME" \ + --arg redirect_uris "$REDIRECT_URI" \ + --arg allowed_scopes "$ALLOWED_SCOPES" \ + '$ARGS.named | .redirect_uris = [.redirect_uris] | .allowed_scopes = (.allowed_scopes | split(" ")) | .confidential = true') + + rauthy_api_request POST "/clients" "$PAYLOAD" + if [ "$API_HTTP_STATUS" != "200" ] && [ "$API_HTTP_STATUS" != "201" ]; then + echo "ERROR: failed to create client '${CLIENT_ID}' (HTTP ${API_HTTP_STATUS}): ${API_BODY}" >&2 + exit 1 + fi + fi + + rauthy_api_request POST "/clients/${CLIENT_ID}/secret" + if [ "$API_HTTP_STATUS" != "200" ] && [ "$API_HTTP_STATUS" != "201" ]; then + echo "ERROR: failed to fetch secret for client '${CLIENT_ID}' (HTTP ${API_HTTP_STATUS}): ${API_BODY}" >&2 + exit 1 + fi + + local CLIENT_SECRET + CLIENT_SECRET=$(echo "$API_BODY" | jq -r '.secret // empty') + + if [ -z "$CLIENT_SECRET" ]; then + echo "ERROR: no secret in API response for '${CLIENT_ID}'" >&2 + echo "Response was: ${API_BODY}" >&2 + exit 1 + fi + + if [ "$MODE" = "insertsecret" ]; then + echo "Undeploying '${APP_NAME}' to replace secret '${CLIENT_ID}_sec'" + abra --no-input app undeploy "$APP_NAME" || true + abra app secret remove -C "$APP_NAME" "${CLIENT_ID}_sec" || true + if printf '%s' "$CLIENT_SECRET" | abra app secret insert -C "$APP_NAME" "${CLIENT_ID}_sec" v1; then + echo "Secret '${CLIENT_ID}_sec' inserted, redeploying '${APP_NAME}'..." + else + echo "ERROR: failed to insert secret '${CLIENT_ID}_sec'; redeploying app" >&2 + fi + abra --no-input app deploy -C "$APP_NAME" || true + #rauthy doesn't have a healthcheck, wait 5 seconds for startup + sleep 5 + + else + echo "Client '${CLIENT_ID}' created. Secret: ${CLIENT_SECRET}" + fi +} + +# Creates one or more groups in Rauthy. +# Usage: create_groups [ ...] +create_groups() { + if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required. Install with: apt install jq" >&2 + exit 1 + fi + + rauthy_api_request GET "/groups" + local EXISTING_GROUPS + if [ "$API_HTTP_STATUS" = "200" ]; then + EXISTING_GROUPS=$(echo "$API_BODY" | jq -r '.[].name // empty') + fi + + for GROUP_NAME in "$@"; do + if echo "$EXISTING_GROUPS" | grep -qx "$GROUP_NAME"; then + echo "Group '${GROUP_NAME}' already exists, skipping" + continue + fi + + rauthy_api_request POST "/groups" "$(jq -n --arg group "$GROUP_NAME" '$ARGS.named')" + if [ "$API_HTTP_STATUS" != "200" ] && [ "$API_HTTP_STATUS" != "201" ]; then + echo "ERROR: failed to create group '${GROUP_NAME}' (HTTP ${API_HTTP_STATUS}): ${API_BODY}" >&2 + exit 1 + fi + + echo "Created group '${GROUP_NAME}'" + done +} + +# Creates one or more roles in Rauthy. +# Usage: create_roles [ ...] +create_roles() { + if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required. Install with: apt install jq" >&2 + exit 1 + fi + + rauthy_api_request GET "/roles" + local EXISTING_ROLES + EXISTING_ROLES=$(echo "$API_BODY" | jq -r '.[].name // empty') + + for ROLE_NAME in "$@"; do + if echo "$EXISTING_ROLES" | grep -qx "$ROLE_NAME"; then + echo "Role '${ROLE_NAME}' already exists, skipping" + continue + fi + + rauthy_api_request POST "/roles" "$(jq -n --arg role "$ROLE_NAME" '$ARGS.named')" + if [ "$API_HTTP_STATUS" != "200" ] && [ "$API_HTTP_STATUS" != "201" ]; then + echo "ERROR: failed to create role '${ROLE_NAME}' (HTTP ${API_HTTP_STATUS}): ${API_BODY}" >&2 + exit 1 + fi + + echo "Created role '${ROLE_NAME}'" + done +} diff --git a/alaconnect.yml b/alaconnect.yml new file mode 100644 index 0000000..60c476f --- /dev/null +++ b/alaconnect.yml @@ -0,0 +1,10 @@ +nextcloud: + uncomment: + - compose.nextcloud.yml + - SECRET_NEXTCLOUD_SEC_VERSION + - NEXTCLOUD_REDIRECT_URI + - NEXTCLOUD_CLIENT_NAME + initial-hooks: + - local create_client nextcloud insertsecret + shared_secrets: + user_oidc_secret: nextcloud_sec \ No newline at end of file diff --git a/api.key.example.json b/api.key.example.json new file mode 100644 index 0000000..61c46b6 --- /dev/null +++ b/api.key.example.json @@ -0,0 +1,18 @@ +{ + "name":"bootstrap", + "exp":null, + "access":[{ + "group":"Clients", + "access_rights":["read","create"] + },{ + "group":"Roles", + "access_rights":["read","create"] + },{ + "group": "Groups", + "access_rights":["read","create"] + },{ + "group": "Secrets", + "access_rights":["read"] + } + ] +} diff --git a/compose.api.yml b/compose.api.yml new file mode 100644 index 0000000..e085453 --- /dev/null +++ b/compose.api.yml @@ -0,0 +1,11 @@ +services: + app: + environment: + - API_KEY + secrets: + - api_secret + +secrets: + api_secret: + name: ${STACK_NAME}_api_secret_${SECRET_API_SECRET_VERSION} + external: true diff --git a/compose.bootstrapadmin.yml b/compose.bootstrapadmin.yml new file mode 100644 index 0000000..481e968 --- /dev/null +++ b/compose.bootstrapadmin.yml @@ -0,0 +1,9 @@ +services: + app: + secrets: + - admin_pwhash + +secrets: + admin_pwhash: + name: ${STACK_NAME}_admin_pwhash_${SECRET_ADMIN_PWHASH_VERSION} + external: true diff --git a/compose.nextcloud.yml b/compose.nextcloud.yml new file mode 100644 index 0000000..51826f1 --- /dev/null +++ b/compose.nextcloud.yml @@ -0,0 +1,9 @@ +services: + app: + secrets: + - nextcloud_sec + +secrets: + nextcloud_sec: + name: ${STACK_NAME}_nextcloud_sec_${SECRET_NEXTCLOUD_SEC_VERSION} + external: true diff --git a/config.toml.tmpl b/config.toml.tmpl index 54d00ad..73c0fbf 100644 --- a/config.toml.tmpl +++ b/config.toml.tmpl @@ -3,6 +3,9 @@ [bootstrap] admin_email = '{{ env "ADMIN_EMAIL" }}' +pasword_argon2id = '{{ secret "admin_pwhash" }}' +api_key = '{{ env "API_BASE64_ACCESS_RIGHTS" }}' +api_key_secret = '{{ secret "api_secret" }}' [cluster] node_id = 1 diff --git a/release/next b/release/next new file mode 100644 index 0000000..b6d9199 --- /dev/null +++ b/release/next @@ -0,0 +1,4 @@ + +* Added bootstrapping admin password and API key +* Added abra.sh functions for creating clients, groups and roles with the Rauthy API +* Documentation and example for Nextcloud integration