Difference between revisions of "2fa ssh"
Jump to navigation
Jump to search
| Line 44: | Line 44: | ||
sudo systemctl restart ssh | sudo systemctl restart ssh | ||
| + | ``` | ||
| + | |||
| + | |||
| + | # KeyCloak realm | ||
| + | |||
| + | ``` | ||
| + | setup-authd-keycloak.sh | ||
| + | #!/usr/bin/env bash | ||
| + | # setup-authd-keycloak.sh | ||
| + | # | ||
| + | # Idempotent Ubuntu 26.04 Server bootstrap for Canonical authd + generic OIDC | ||
| + | # broker (authd-oidc), intended for Keycloak device authorization. | ||
| + | # | ||
| + | # Default SSH policy for OIDC users: | ||
| + | # - SSH password authentication: disabled | ||
| + | # - SSH public-key authentication: disabled for the OIDC suffix | ||
| + | # - PAM keyboard-interactive/authd: required | ||
| + | # | ||
| + | # This ensures OIDC users cannot bypass authd/Keycloak with an SSH key. | ||
| + | # | ||
| + | # IMPORTANT: | ||
| + | # Keycloak OTP/WebAuthn policy is configured in Keycloak, not by this script. | ||
| + | # Configure the Keycloak client/authentication flow to require the desired MFA. | ||
| + | |||
| + | set -Eeuo pipefail | ||
| + | IFS=$'\n\t' | ||
| + | |||
| + | PROGRAM="${0##*/}" | ||
| + | |||
| + | ISSUER="" | ||
| + | CLIENT_ID="" | ||
| + | SSH_SUFFIX="" | ||
| + | ALLOWED_USERS="" | ||
| + | CLIENT_SECRET="" | ||
| + | EXTRA_SCOPES="offline_access" | ||
| + | FORCE_PROVIDER_AUTHENTICATION="true" | ||
| + | HOME_BASE_DIR="/home" | ||
| + | EXTRA_GROUPS="" | ||
| + | SSH_POLICY="authd-only" | ||
| + | BROKER_CHANNEL="0.x/stable" | ||
| + | SKIP_OIDC_CHECK="false" | ||
| + | |||
| + | BROKER_SNAP="authd-oidc" | ||
| + | BROKER_CONF="" | ||
| + | BROKER_DATA_DIR="" | ||
| + | BROKER_DISCOVERY_SRC="/snap/authd-oidc/current/conf/authd/oidc.conf" | ||
| + | BROKER_DISCOVERY_DST="/etc/authd/brokers.d/oidc.conf" | ||
| + | SSHD_DROPIN="/etc/ssh/sshd_config.d/00-authd-oidc.conf" | ||
| + | |||
| + | usage() { | ||
| + | cat <<EOF | ||
| + | Usage: | ||
| + | sudo ./${PROGRAM} \\ | ||
| + | --issuer URL \\ | ||
| + | --client-id ID \\ | ||
| + | --ssh-suffix @DOMAIN \\ | ||
| + | --allowed-users USERS | ||
| + | |||
| + | Required: | ||
| + | --issuer URL | ||
| + | OIDC issuer / Keycloak realm URL. | ||
| + | Example: https://auth.example.com/realms/linux | ||
| + | |||
| + | --client-id ID | ||
| + | Keycloak OIDC client ID. | ||
| + | Example: ubuntu-authd | ||
| + | |||
| + | --ssh-suffix @DOMAIN | ||
| + | Username suffix allowed to perform first SSH authentication. | ||
| + | Exactly one suffix is supported by this script. | ||
| + | Example: @example.com | ||
| + | |||
| + | --allowed-users USERS | ||
| + | authd broker allowed_users value. | ||
| + | Examples: | ||
| + | ALL | ||
| + | OWNER | ||
| + | user1@example.com,user2@example.com | ||
| + | |||
| + | Optional: | ||
| + | --client-secret SECRET | ||
| + | OIDC client secret, only when your Keycloak client requires one. | ||
| + | Prefer a public client for device authorization when appropriate. | ||
| + | WARNING: a secret supplied on the command line may be visible in shell | ||
| + | history or process listings. | ||
| + | |||
| + | --extra-scopes SCOPES | ||
| + | Comma-separated additional OIDC scopes. | ||
| + | Default: offline_access | ||
| + | |||
| + | --force-provider-authentication true|false | ||
| + | Require provider/token refresh validation even for authd local-password | ||
| + | authentication. | ||
| + | Default: true | ||
| + | |||
| + | --home-base-dir DIR | ||
| + | Base directory for authd-created home directories. | ||
| + | Default: /home | ||
| + | |||
| + | --extra-groups GROUPS | ||
| + | Optional comma-separated local groups to assign to authd users. | ||
| + | Example: sudo | ||
| + | Default: unset | ||
| + | |||
| + | --ssh-policy POLICY | ||
| + | authd-only Require authd/Keycloak for OIDC users. This is the | ||
| + | recommended default for JIT-provisioned SSH users. | ||
| + | publickey+authd Require BOTH SSH public key AND authd/Keycloak. | ||
| + | The account/key must already be resolvable before SSH | ||
| + | reaches authd, so this is normally unsuitable for the | ||
| + | very first JIT login. | ||
| + | Default: authd-only | ||
| + | |||
| + | --broker-channel CHANNEL | ||
| + | Snap channel used when authd-oidc is first installed. | ||
| + | Existing installations are not automatically switched. | ||
| + | Default: 0.x/stable | ||
| + | |||
| + | --skip-oidc-check | ||
| + | Do not validate the issuer discovery document before changing config. | ||
| + | |||
| + | -h, --help | ||
| + | Show this help. | ||
| + | |||
| + | Example: | ||
| + | sudo ./${PROGRAM} \\ | ||
| + | --issuer https://auth.uvoo.io/realms/test \\ | ||
| + | --client-id test \\ | ||
| + | --ssh-suffix @uvoo.io \\ | ||
| + | --allowed-users ALL | ||
| + | |||
| + | More restrictive production example: | ||
| + | sudo ./${PROGRAM} \\ | ||
| + | --issuer https://auth.uvoo.io/realms/test \\ | ||
| + | --client-id test \\ | ||
| + | --ssh-suffix @uvoo.io \\ | ||
| + | --allowed-users 'test1@uvoo.io,admin1@uvoo.io' | ||
| + | EOF | ||
| + | } | ||
| + | |||
| + | die() { | ||
| + | printf 'ERROR: %s\n' "$*" >&2 | ||
| + | exit 1 | ||
| + | } | ||
| + | |||
| + | log() { | ||
| + | printf '==> %s\n' "$*" | ||
| + | } | ||
| + | |||
| + | on_error() { | ||
| + | local rc=$? | ||
| + | printf 'ERROR: command failed at line %s (exit %s)\n' "${BASH_LINENO[0]}" "$rc" >&2 | ||
| + | exit "$rc" | ||
| + | } | ||
| + | trap on_error ERR | ||
| + | |||
| + | require_value() { | ||
| + | local opt="$1" | ||
| + | local value="${2-}" | ||
| + | [[ -n "$value" ]] || die "${opt} requires a value" | ||
| + | } | ||
| + | |||
| + | while (($#)); do | ||
| + | case "$1" in | ||
| + | --issuer) | ||
| + | require_value "$1" "${2-}" | ||
| + | ISSUER="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --client-id) | ||
| + | require_value "$1" "${2-}" | ||
| + | CLIENT_ID="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --ssh-suffix) | ||
| + | require_value "$1" "${2-}" | ||
| + | SSH_SUFFIX="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --allowed-users) | ||
| + | require_value "$1" "${2-}" | ||
| + | ALLOWED_USERS="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --client-secret) | ||
| + | require_value "$1" "${2-}" | ||
| + | CLIENT_SECRET="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --extra-scopes) | ||
| + | require_value "$1" "${2-}" | ||
| + | EXTRA_SCOPES="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --force-provider-authentication) | ||
| + | require_value "$1" "${2-}" | ||
| + | FORCE_PROVIDER_AUTHENTICATION="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --home-base-dir) | ||
| + | require_value "$1" "${2-}" | ||
| + | HOME_BASE_DIR="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --extra-groups) | ||
| + | require_value "$1" "${2-}" | ||
| + | EXTRA_GROUPS="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --ssh-policy) | ||
| + | require_value "$1" "${2-}" | ||
| + | SSH_POLICY="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --broker-channel) | ||
| + | require_value "$1" "${2-}" | ||
| + | BROKER_CHANNEL="$2" | ||
| + | shift 2 | ||
| + | ;; | ||
| + | --skip-oidc-check) | ||
| + | SKIP_OIDC_CHECK="true" | ||
| + | shift | ||
| + | ;; | ||
| + | -h|--help) | ||
| + | usage | ||
| + | exit 0 | ||
| + | ;; | ||
| + | *) | ||
| + | die "unknown argument: $1 (use --help)" | ||
| + | ;; | ||
| + | esac | ||
| + | done | ||
| + | |||
| + | [[ -n "$ISSUER" ]] || die "--issuer is required" | ||
| + | [[ -n "$CLIENT_ID" ]] || die "--client-id is required" | ||
| + | [[ -n "$SSH_SUFFIX" ]] || die "--ssh-suffix is required" | ||
| + | [[ -n "$ALLOWED_USERS" ]] || die "--allowed-users is required" | ||
| + | |||
| + | [[ "$ISSUER" == https://* ]] || die "--issuer must use https://" | ||
| + | ISSUER="${ISSUER%/}" | ||
| + | |||
| + | [[ "$SSH_SUFFIX" == @* ]] || die "--ssh-suffix must begin with '@'" | ||
| + | [[ "$SSH_SUFFIX" != *","* ]] || die "--ssh-suffix accepts exactly one suffix" | ||
| + | [[ "$SSH_SUFFIX" != *[[:space:]]* ]] || die "--ssh-suffix must not contain whitespace" | ||
| + | |||
| + | case "$FORCE_PROVIDER_AUTHENTICATION" in | ||
| + | true|false) ;; | ||
| + | *) die "--force-provider-authentication must be true or false" ;; | ||
| + | esac | ||
| + | |||
| + | case "$SSH_POLICY" in | ||
| + | authd-only|publickey+authd) ;; | ||
| + | *) die "--ssh-policy must be authd-only or publickey+authd" ;; | ||
| + | esac | ||
| + | |||
| + | for value_name in ISSUER CLIENT_ID SSH_SUFFIX ALLOWED_USERS CLIENT_SECRET EXTRA_SCOPES HOME_BASE_DIR EXTRA_GROUPS; do | ||
| + | value="${!value_name}" | ||
| + | [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] \ | ||
| + | || die "${value_name} must not contain newlines" | ||
| + | done | ||
| + | |||
| + | if [[ $EUID -ne 0 ]]; then | ||
| + | die "run this script as root, e.g. sudo ./${PROGRAM} ..." | ||
| + | fi | ||
| + | |||
| + | [[ -r /etc/os-release ]] || die "cannot read /etc/os-release" | ||
| + | # shellcheck disable=SC1091 | ||
| + | source /etc/os-release | ||
| + | [[ "${ID:-}" == "ubuntu" ]] || die "this script supports Ubuntu only" | ||
| + | [[ "${VERSION_ID:-}" == "26.04" ]] \ | ||
| + | || die "this script is intentionally limited to Ubuntu 26.04; found ${VERSION_ID:-unknown}" | ||
| + | |||
| + | export DEBIAN_FRONTEND=noninteractive | ||
| + | |||
| + | install_packages() { | ||
| + | local required=( | ||
| + | authd | ||
| + | openssh-server | ||
| + | snapd | ||
| + | curl | ||
| + | ca-certificates | ||
| + | python3 | ||
| + | cracklib-runtime | ||
| + | wamerican | ||
| + | ) | ||
| + | local missing=() | ||
| + | local pkg | ||
| + | |||
| + | for pkg in "${required[@]}"; do | ||
| + | if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -qx 'install ok installed'; then | ||
| + | missing+=("$pkg") | ||
| + | fi | ||
| + | done | ||
| + | |||
| + | if ((${#missing[@]})); then | ||
| + | log "Installing required packages: ${missing[*]}" | ||
| + | apt-get update | ||
| + | apt-get install -y --no-install-recommends "${missing[@]}" | ||
| + | else | ||
| + | log "Required Debian packages already installed" | ||
| + | fi | ||
| + | |||
| + | systemctl enable --now snapd.socket >/dev/null | ||
| + | # authd may be socket/D-Bus activated depending on package version; start it | ||
| + | # without assuming the unit has an [Install] section. | ||
| + | systemctl start authd | ||
| + | systemctl enable --now ssh >/dev/null | ||
| + | } | ||
| + | |||
| + | install_broker() { | ||
| + | if snap list "$BROKER_SNAP" >/dev/null 2>&1; then | ||
| + | log "$BROKER_SNAP is already installed; leaving its current snap channel/revision unchanged" | ||
| + | snap list "$BROKER_SNAP" | ||
| + | else | ||
| + | log "Installing $BROKER_SNAP from channel $BROKER_CHANNEL" | ||
| + | snap install "$BROKER_SNAP" --channel="$BROKER_CHANNEL" | ||
| + | fi | ||
| + | |||
| + | # Resolve the active snap revision explicitly. The service wrapper uses the | ||
| + | # revision-specific SNAP_DATA path (for example /var/snap/authd-oidc/89). | ||
| + | local revision | ||
| + | revision="$(snap list "$BROKER_SNAP" | awk 'NR==2 {print $3}')" | ||
| + | [[ "$revision" =~ ^[0-9]+$ ]] || die "could not determine active $BROKER_SNAP revision" | ||
| + | |||
| + | BROKER_DATA_DIR="/var/snap/${BROKER_SNAP}/${revision}" | ||
| + | BROKER_CONF="${BROKER_DATA_DIR}/broker.conf" | ||
| + | |||
| + | [[ -f "$BROKER_CONF" ]] \ | ||
| + | || die "broker configuration was not created at $BROKER_CONF" | ||
| + | [[ -f "$BROKER_DISCOVERY_SRC" ]] \ | ||
| + | || die "broker discovery file not found at $BROKER_DISCOVERY_SRC" | ||
| + | |||
| + | log "Active broker revision: $revision" | ||
| + | log "Active broker config: $BROKER_CONF" | ||
| + | } | ||
| + | |||
| + | validate_oidc() { | ||
| + | [[ "$SKIP_OIDC_CHECK" == "true" ]] && { | ||
| + | log "Skipping OIDC discovery validation" | ||
| + | return | ||
| + | } | ||
| + | |||
| + | local discovery="${ISSUER}/.well-known/openid-configuration" | ||
| + | local tmp | ||
| + | tmp="$(mktemp)" | ||
| + | trap 'rm -f "$tmp"' RETURN | ||
| + | |||
| + | log "Validating OIDC discovery document: $discovery" | ||
| + | curl --fail --silent --show-error --location \ | ||
| + | --connect-timeout 5 --max-time 15 \ | ||
| + | "$discovery" -o "$tmp" | ||
| + | |||
| + | python3 - "$tmp" "$ISSUER" <<'PY' | ||
| + | import json | ||
| + | import sys | ||
| + | |||
| + | path, expected_issuer = sys.argv[1], sys.argv[2] | ||
| + | with open(path, encoding="utf-8") as f: | ||
| + | d = json.load(f) | ||
| + | |||
| + | actual_issuer = str(d.get("issuer", "")).rstrip("/") | ||
| + | if actual_issuer != expected_issuer.rstrip("/"): | ||
| + | raise SystemExit( | ||
| + | f"OIDC discovery issuer mismatch: expected {expected_issuer!r}, got {actual_issuer!r}" | ||
| + | ) | ||
| + | |||
| + | device_ep = d.get("device_authorization_endpoint") | ||
| + | if not device_ep: | ||
| + | raise SystemExit( | ||
| + | "OIDC provider does not advertise device_authorization_endpoint; " | ||
| + | "authd Device Authentication will not work." | ||
| + | ) | ||
| + | |||
| + | print(f"OIDC issuer OK: {actual_issuer}") | ||
| + | print(f"Device authorization endpoint: {device_ep}") | ||
| + | PY | ||
| + | |||
| + | rm -f "$tmp" | ||
| + | trap - RETURN | ||
| + | } | ||
| + | |||
| + | install_if_changed() { | ||
| + | local source="$1" | ||
| + | local destination="$2" | ||
| + | local mode="$3" | ||
| + | |||
| + | if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then | ||
| + | return 1 | ||
| + | fi | ||
| + | |||
| + | install -D -m "$mode" "$source" "$destination" | ||
| + | return 0 | ||
| + | } | ||
| + | |||
| + | validate_broker_files_no_placeholders() { | ||
| + | local bad=0 | ||
| + | local f | ||
| + | |||
| + | # authd-oidc 0.4.1 rejects any parsed config value containing both '<' and '>'. | ||
| + | # Scan the active main file and all drop-ins before attempting a restart. | ||
| + | if grep -nE '<[^>]+>' "$BROKER_CONF" >/dev/null 2>&1; then | ||
| + | printf 'ERROR: unresolved template placeholder(s) in %s:\n' "$BROKER_CONF" >&2 | ||
| + | grep -nE '<[^>]+>' "$BROKER_CONF" >&2 || true | ||
| + | bad=1 | ||
| + | fi | ||
| + | |||
| + | if [[ -d "${BROKER_CONF}.d" ]]; then | ||
| + | while IFS= read -r -d '' f; do | ||
| + | if grep -nE '<[^>]+>' "$f" >/dev/null 2>&1; then | ||
| + | printf 'ERROR: unresolved template placeholder(s) in drop-in %s:\n' "$f" >&2 | ||
| + | grep -nE '<[^>]+>' "$f" >&2 || true | ||
| + | bad=1 | ||
| + | fi | ||
| + | done < <(find "${BROKER_CONF}.d" -maxdepth 1 -type f -print0) | ||
| + | fi | ||
| + | |||
| + | (( bad == 0 )) || die "authd-oidc configuration contains unresolved <...> placeholders" | ||
| + | } | ||
| + | |||
| + | configure_broker_discovery() { | ||
| + | mkdir -p /etc/authd/brokers.d | ||
| + | |||
| + | if install_if_changed "$BROKER_DISCOVERY_SRC" "$BROKER_DISCOVERY_DST" 0644; then | ||
| + | log "Updated authd broker discovery: $BROKER_DISCOVERY_DST" | ||
| + | AUTHD_CHANGED=true | ||
| + | else | ||
| + | log "authd broker discovery already correct" | ||
| + | fi | ||
| + | } | ||
| + | |||
| + | configure_broker() { | ||
| + | local tmp | ||
| + | tmp="$(mktemp)" | ||
| + | trap 'rm -f "$tmp"' RETURN | ||
| + | |||
| + | { | ||
| + | printf '[oidc]\n' | ||
| + | printf 'issuer = %s\n' "$ISSUER" | ||
| + | printf 'client_id = %s\n' "$CLIENT_ID" | ||
| + | |||
| + | if [[ -n "$CLIENT_SECRET" ]]; then | ||
| + | printf 'client_secret = %s\n' "$CLIENT_SECRET" | ||
| + | fi | ||
| + | |||
| + | if [[ -n "$EXTRA_SCOPES" ]]; then | ||
| + | printf 'extra_scopes = %s\n' "$EXTRA_SCOPES" | ||
| + | fi | ||
| + | |||
| + | printf 'force_provider_authentication = %s\n' "$FORCE_PROVIDER_AUTHENTICATION" | ||
| + | printf '\n' | ||
| + | printf '[users]\n' | ||
| + | printf 'ssh_allowed_suffixes_first_auth = %s\n' "$SSH_SUFFIX" | ||
| + | printf 'allowed_users = %s\n' "$ALLOWED_USERS" | ||
| + | printf 'home_base_dir = %s\n' "$HOME_BASE_DIR" | ||
| + | |||
| + | if [[ -n "$EXTRA_GROUPS" ]]; then | ||
| + | printf 'extra_groups = %s\n' "$EXTRA_GROUPS" | ||
| + | fi | ||
| + | } >"$tmp" | ||
| + | |||
| + | # Preserve the snap-provided/generated file the first time we take control. | ||
| + | if [[ -f "$BROKER_CONF" && ! -e "${BROKER_CONF}.pre-${PROGRAM}" ]]; then | ||
| + | cp -a "$BROKER_CONF" "${BROKER_CONF}.pre-${PROGRAM}" | ||
| + | log "Saved original broker config as ${BROKER_CONF}.pre-${PROGRAM}" | ||
| + | fi | ||
| + | |||
| + | if install_if_changed "$tmp" "$BROKER_CONF" 0600; then | ||
| + | log "Updated broker configuration: $BROKER_CONF" | ||
| + | BROKER_CHANGED=true | ||
| + | else | ||
| + | log "Broker configuration already correct" | ||
| + | fi | ||
| + | |||
| + | rm -f "$tmp" | ||
| + | trap - RETURN | ||
| + | |||
| + | validate_broker_files_no_placeholders | ||
| + | } | ||
| + | |||
| + | configure_sshd() { | ||
| + | local tmp | ||
| + | tmp="$(mktemp)" | ||
| + | trap 'rm -f "$tmp"' RETURN | ||
| + | |||
| + | cat >"$tmp" <<EOF | ||
| + | # Managed by ${PROGRAM} | ||
| + | # | ||
| + | # Local/break-glass Unix accounts may still use SSH public keys. | ||
| + | # OIDC users matching ${SSH_SUFFIX} are forced through authd/PAM. | ||
| + | |||
| + | UsePAM yes | ||
| + | PasswordAuthentication no | ||
| + | KbdInteractiveAuthentication no | ||
| + | PubkeyAuthentication yes | ||
| + | |||
| + | Match User *${SSH_SUFFIX} | ||
| + | PasswordAuthentication no | ||
| + | KbdInteractiveAuthentication yes | ||
| + | EOF | ||
| + | |||
| + | case "$SSH_POLICY" in | ||
| + | authd-only) | ||
| + | cat >>"$tmp" <<'EOF' | ||
| + | PubkeyAuthentication no | ||
| + | AuthenticationMethods keyboard-interactive:pam | ||
| + | EOF | ||
| + | ;; | ||
| + | publickey+authd) | ||
| + | cat >>"$tmp" <<'EOF' | ||
| + | PubkeyAuthentication yes | ||
| + | AuthenticationMethods publickey,keyboard-interactive:pam | ||
| + | EOF | ||
| + | ;; | ||
| + | esac | ||
| + | |||
| + | if install_if_changed "$tmp" "$SSHD_DROPIN" 0644; then | ||
| + | log "Updated SSH configuration: $SSHD_DROPIN" | ||
| + | SSH_CHANGED=true | ||
| + | else | ||
| + | log "SSH configuration already correct" | ||
| + | fi | ||
| + | |||
| + | rm -f "$tmp" | ||
| + | trap - RETURN | ||
| + | |||
| + | log "Validating sshd configuration" | ||
| + | sshd -t | ||
| + | } | ||
| + | |||
| + | restart_if_needed() { | ||
| + | if [[ "$AUTHD_CHANGED" == "true" ]]; then | ||
| + | log "Restarting authd" | ||
| + | systemctl restart authd | ||
| + | fi | ||
| + | |||
| + | if [[ "$BROKER_CHANGED" == "true" || "$AUTHD_CHANGED" == "true" ]]; then | ||
| + | log "Restarting authd-oidc broker" | ||
| + | if ! snap restart authd-oidc >/dev/null; then | ||
| + | printf 'ERROR: authd-oidc failed to restart. Recent broker log:\n' >&2 | ||
| + | journalctl -u snap.authd-oidc.authd-oidc.service -b -n 60 --no-pager >&2 || true | ||
| + | |||
| + | if [[ -f "${BROKER_CONF}.pre-${PROGRAM}" ]]; then | ||
| + | printf 'ERROR: restoring previous broker configuration and retrying service start\n' >&2 | ||
| + | cp -a "${BROKER_CONF}.pre-${PROGRAM}" "$BROKER_CONF" | ||
| + | systemctl reset-failed snap.authd-oidc.authd-oidc.service || true | ||
| + | snap restart authd-oidc >/dev/null || true | ||
| + | fi | ||
| + | die "authd-oidc broker restart failed; previous config was restored when available" | ||
| + | fi | ||
| + | fi | ||
| + | |||
| + | if [[ "$SSH_CHANGED" == "true" ]]; then | ||
| + | log "Restarting SSH" | ||
| + | systemctl restart ssh | ||
| + | fi | ||
| + | } | ||
| + | |||
| + | show_effective_config() { | ||
| + | local probe_user="authd-probe${SSH_SUFFIX}" | ||
| + | |||
| + | log "Service status" | ||
| + | systemctl is-active --quiet authd && echo "authd: active" | ||
| + | snap services authd-oidc | ||
| + | systemctl is-active --quiet ssh && echo "ssh: active" | ||
| + | |||
| + | log "Effective SSH authentication settings for ${probe_user}" | ||
| + | sshd -T -C "user=${probe_user},host=localhost,addr=127.0.0.1" 2>/dev/null \ | ||
| + | | grep -E '^(usepam|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|authenticationmethods) ' \ | ||
| + | || true | ||
| + | |||
| + | log "Installed versions" | ||
| + | dpkg-query -W -f='authd: ${Version}\n' authd 2>/dev/null || true | ||
| + | snap list authd-oidc 2>/dev/null || true | ||
| + | |||
| + | cat <<EOF | ||
| + | |||
| + | Setup complete. | ||
| + | |||
| + | OIDC issuer: ${ISSUER} | ||
| + | OIDC client ID: ${CLIENT_ID} | ||
| + | SSH suffix: ${SSH_SUFFIX} | ||
| + | Allowed users: ${ALLOWED_USERS} | ||
| + | SSH policy: ${SSH_POLICY} | ||
| + | |||
| + | Test from a SECOND terminal while keeping your current administrative | ||
| + | session open: | ||
| + | |||
| + | ssh 'user${SSH_SUFFIX}'@<server> | ||
| + | |||
| + | For Keycloak MFA: | ||
| + | Configure the Keycloak authentication flow/client so Device Authorization | ||
| + | requires OTP/WebAuthn. This script configures the Ubuntu side only. | ||
| + | |||
| + | EOF | ||
| + | |||
| + | if [[ "$SSH_POLICY" == "publickey+authd" ]]; then | ||
| + | cat <<'EOF' | ||
| + | NOTE: | ||
| + | publickey+authd requires the SSH public key to succeed before authd runs. | ||
| + | For a brand-new JIT user, pre-provision the account/key or use centralized | ||
| + | SSH certificates / AuthorizedKeysCommand. Otherwise first login can fail. | ||
| + | |||
| + | EOF | ||
| + | fi | ||
| + | } | ||
| + | |||
| + | AUTHD_CHANGED=false | ||
| + | BROKER_CHANGED=false | ||
| + | SSH_CHANGED=false | ||
| + | |||
| + | install_packages | ||
| + | install_broker | ||
| + | validate_oidc | ||
| + | configure_broker_discovery | ||
| + | configure_broker | ||
| + | configure_sshd | ||
| + | restart_if_needed | ||
| + | show_effective_config | ||
``` | ``` | ||
Revision as of 12:40, 16 September 2026
Using 2FA
Require OTP via Google Authenticator App
publickey,keyboard-interactive (and - requires both)
publickey keyboard-interactive (or - requires one or the other)
Ubuntu 26.04
Prep user
adduser myuser --disabled-password # usermod -aG sudo myuser visudo # myuser ALL=(ALL:ALL) NOPASSWD: ALL sudo su - myuser mkdir .ssh nano .ssh/authorized_keys # paste public ssh key
Update SSHD to require both SSH ID and then OTP
#!/usr/bin/env bash set -e sudo apt update sudo apt install -y libpam-google-authenticator # Run as the login user, NOT root/sudo — generates ~/.google_authenticator google-authenticator # Insert BEFORE @include common-auth if you want pubkey+OTP only, no password. # As a quick check, comment out the password-based auth line so PAM only asks for the OTP: sudo sed -i 's/^@include common-auth/#&/' /etc/pam.d/sshd sudo sed -i '1 i\auth required pam_google_authenticator.so' /etc/pam.d/sshd sudo tee /etc/ssh/sshd_config.d/60-mfa.conf > /dev/null <<'EOF' KbdInteractiveAuthentication yes PasswordAuthentication no AuthenticationMethods publickey,keyboard-interactive EOF sudo systemctl restart ssh
KeyCloak realm
setup-authd-keycloak.sh
#!/usr/bin/env bash
# setup-authd-keycloak.sh
#
# Idempotent Ubuntu 26.04 Server bootstrap for Canonical authd + generic OIDC
# broker (authd-oidc), intended for Keycloak device authorization.
#
# Default SSH policy for OIDC users:
# - SSH password authentication: disabled
# - SSH public-key authentication: disabled for the OIDC suffix
# - PAM keyboard-interactive/authd: required
#
# This ensures OIDC users cannot bypass authd/Keycloak with an SSH key.
#
# IMPORTANT:
# Keycloak OTP/WebAuthn policy is configured in Keycloak, not by this script.
# Configure the Keycloak client/authentication flow to require the desired MFA.
set -Eeuo pipefail
IFS=$'\n\t'
PROGRAM="${0##*/}"
ISSUER=""
CLIENT_ID=""
SSH_SUFFIX=""
ALLOWED_USERS=""
CLIENT_SECRET=""
EXTRA_SCOPES="offline_access"
FORCE_PROVIDER_AUTHENTICATION="true"
HOME_BASE_DIR="/home"
EXTRA_GROUPS=""
SSH_POLICY="authd-only"
BROKER_CHANNEL="0.x/stable"
SKIP_OIDC_CHECK="false"
BROKER_SNAP="authd-oidc"
BROKER_CONF=""
BROKER_DATA_DIR=""
BROKER_DISCOVERY_SRC="/snap/authd-oidc/current/conf/authd/oidc.conf"
BROKER_DISCOVERY_DST="/etc/authd/brokers.d/oidc.conf"
SSHD_DROPIN="/etc/ssh/sshd_config.d/00-authd-oidc.conf"
usage() {
cat <<EOF
Usage:
sudo ./${PROGRAM} \\
--issuer URL \\
--client-id ID \\
--ssh-suffix @DOMAIN \\
--allowed-users USERS
Required:
--issuer URL
OIDC issuer / Keycloak realm URL.
Example: https://auth.example.com/realms/linux
--client-id ID
Keycloak OIDC client ID.
Example: ubuntu-authd
--ssh-suffix @DOMAIN
Username suffix allowed to perform first SSH authentication.
Exactly one suffix is supported by this script.
Example: @example.com
--allowed-users USERS
authd broker allowed_users value.
Examples:
ALL
OWNER
user1@example.com,user2@example.com
Optional:
--client-secret SECRET
OIDC client secret, only when your Keycloak client requires one.
Prefer a public client for device authorization when appropriate.
WARNING: a secret supplied on the command line may be visible in shell
history or process listings.
--extra-scopes SCOPES
Comma-separated additional OIDC scopes.
Default: offline_access
--force-provider-authentication true|false
Require provider/token refresh validation even for authd local-password
authentication.
Default: true
--home-base-dir DIR
Base directory for authd-created home directories.
Default: /home
--extra-groups GROUPS
Optional comma-separated local groups to assign to authd users.
Example: sudo
Default: unset
--ssh-policy POLICY
authd-only Require authd/Keycloak for OIDC users. This is the
recommended default for JIT-provisioned SSH users.
publickey+authd Require BOTH SSH public key AND authd/Keycloak.
The account/key must already be resolvable before SSH
reaches authd, so this is normally unsuitable for the
very first JIT login.
Default: authd-only
--broker-channel CHANNEL
Snap channel used when authd-oidc is first installed.
Existing installations are not automatically switched.
Default: 0.x/stable
--skip-oidc-check
Do not validate the issuer discovery document before changing config.
-h, --help
Show this help.
Example:
sudo ./${PROGRAM} \\
--issuer https://auth.uvoo.io/realms/test \\
--client-id test \\
--ssh-suffix @uvoo.io \\
--allowed-users ALL
More restrictive production example:
sudo ./${PROGRAM} \\
--issuer https://auth.uvoo.io/realms/test \\
--client-id test \\
--ssh-suffix @uvoo.io \\
--allowed-users 'test1@uvoo.io,admin1@uvoo.io'
EOF
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
log() {
printf '==> %s\n' "$*"
}
on_error() {
local rc=$?
printf 'ERROR: command failed at line %s (exit %s)\n' "${BASH_LINENO[0]}" "$rc" >&2
exit "$rc"
}
trap on_error ERR
require_value() {
local opt="$1"
local value="${2-}"
[[ -n "$value" ]] || die "${opt} requires a value"
}
while (($#)); do
case "$1" in
--issuer)
require_value "$1" "${2-}"
ISSUER="$2"
shift 2
;;
--client-id)
require_value "$1" "${2-}"
CLIENT_ID="$2"
shift 2
;;
--ssh-suffix)
require_value "$1" "${2-}"
SSH_SUFFIX="$2"
shift 2
;;
--allowed-users)
require_value "$1" "${2-}"
ALLOWED_USERS="$2"
shift 2
;;
--client-secret)
require_value "$1" "${2-}"
CLIENT_SECRET="$2"
shift 2
;;
--extra-scopes)
require_value "$1" "${2-}"
EXTRA_SCOPES="$2"
shift 2
;;
--force-provider-authentication)
require_value "$1" "${2-}"
FORCE_PROVIDER_AUTHENTICATION="$2"
shift 2
;;
--home-base-dir)
require_value "$1" "${2-}"
HOME_BASE_DIR="$2"
shift 2
;;
--extra-groups)
require_value "$1" "${2-}"
EXTRA_GROUPS="$2"
shift 2
;;
--ssh-policy)
require_value "$1" "${2-}"
SSH_POLICY="$2"
shift 2
;;
--broker-channel)
require_value "$1" "${2-}"
BROKER_CHANNEL="$2"
shift 2
;;
--skip-oidc-check)
SKIP_OIDC_CHECK="true"
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1 (use --help)"
;;
esac
done
[[ -n "$ISSUER" ]] || die "--issuer is required"
[[ -n "$CLIENT_ID" ]] || die "--client-id is required"
[[ -n "$SSH_SUFFIX" ]] || die "--ssh-suffix is required"
[[ -n "$ALLOWED_USERS" ]] || die "--allowed-users is required"
[[ "$ISSUER" == https://* ]] || die "--issuer must use https://"
ISSUER="${ISSUER%/}"
[[ "$SSH_SUFFIX" == @* ]] || die "--ssh-suffix must begin with '@'"
[[ "$SSH_SUFFIX" != *","* ]] || die "--ssh-suffix accepts exactly one suffix"
[[ "$SSH_SUFFIX" != *[[:space:]]* ]] || die "--ssh-suffix must not contain whitespace"
case "$FORCE_PROVIDER_AUTHENTICATION" in
true|false) ;;
*) die "--force-provider-authentication must be true or false" ;;
esac
case "$SSH_POLICY" in
authd-only|publickey+authd) ;;
*) die "--ssh-policy must be authd-only or publickey+authd" ;;
esac
for value_name in ISSUER CLIENT_ID SSH_SUFFIX ALLOWED_USERS CLIENT_SECRET EXTRA_SCOPES HOME_BASE_DIR EXTRA_GROUPS; do
value="${!value_name}"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] \
|| die "${value_name} must not contain newlines"
done
if [[ $EUID -ne 0 ]]; then
die "run this script as root, e.g. sudo ./${PROGRAM} ..."
fi
[[ -r /etc/os-release ]] || die "cannot read /etc/os-release"
# shellcheck disable=SC1091
source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "this script supports Ubuntu only"
[[ "${VERSION_ID:-}" == "26.04" ]] \
|| die "this script is intentionally limited to Ubuntu 26.04; found ${VERSION_ID:-unknown}"
export DEBIAN_FRONTEND=noninteractive
install_packages() {
local required=(
authd
openssh-server
snapd
curl
ca-certificates
python3
cracklib-runtime
wamerican
)
local missing=()
local pkg
for pkg in "${required[@]}"; do
if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -qx 'install ok installed'; then
missing+=("$pkg")
fi
done
if ((${#missing[@]})); then
log "Installing required packages: ${missing[*]}"
apt-get update
apt-get install -y --no-install-recommends "${missing[@]}"
else
log "Required Debian packages already installed"
fi
systemctl enable --now snapd.socket >/dev/null
# authd may be socket/D-Bus activated depending on package version; start it
# without assuming the unit has an [Install] section.
systemctl start authd
systemctl enable --now ssh >/dev/null
}
install_broker() {
if snap list "$BROKER_SNAP" >/dev/null 2>&1; then
log "$BROKER_SNAP is already installed; leaving its current snap channel/revision unchanged"
snap list "$BROKER_SNAP"
else
log "Installing $BROKER_SNAP from channel $BROKER_CHANNEL"
snap install "$BROKER_SNAP" --channel="$BROKER_CHANNEL"
fi
# Resolve the active snap revision explicitly. The service wrapper uses the
# revision-specific SNAP_DATA path (for example /var/snap/authd-oidc/89).
local revision
revision="$(snap list "$BROKER_SNAP" | awk 'NR==2 {print $3}')"
[[ "$revision" =~ ^[0-9]+$ ]] || die "could not determine active $BROKER_SNAP revision"
BROKER_DATA_DIR="/var/snap/${BROKER_SNAP}/${revision}"
BROKER_CONF="${BROKER_DATA_DIR}/broker.conf"
[[ -f "$BROKER_CONF" ]] \
|| die "broker configuration was not created at $BROKER_CONF"
[[ -f "$BROKER_DISCOVERY_SRC" ]] \
|| die "broker discovery file not found at $BROKER_DISCOVERY_SRC"
log "Active broker revision: $revision"
log "Active broker config: $BROKER_CONF"
}
validate_oidc() {
[[ "$SKIP_OIDC_CHECK" == "true" ]] && {
log "Skipping OIDC discovery validation"
return
}
local discovery="${ISSUER}/.well-known/openid-configuration"
local tmp
tmp="$(mktemp)"
trap 'rm -f "$tmp"' RETURN
log "Validating OIDC discovery document: $discovery"
curl --fail --silent --show-error --location \
--connect-timeout 5 --max-time 15 \
"$discovery" -o "$tmp"
python3 - "$tmp" "$ISSUER" <<'PY'
import json
import sys
path, expected_issuer = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as f:
d = json.load(f)
actual_issuer = str(d.get("issuer", "")).rstrip("/")
if actual_issuer != expected_issuer.rstrip("/"):
raise SystemExit(
f"OIDC discovery issuer mismatch: expected {expected_issuer!r}, got {actual_issuer!r}"
)
device_ep = d.get("device_authorization_endpoint")
if not device_ep:
raise SystemExit(
"OIDC provider does not advertise device_authorization_endpoint; "
"authd Device Authentication will not work."
)
print(f"OIDC issuer OK: {actual_issuer}")
print(f"Device authorization endpoint: {device_ep}")
PY
rm -f "$tmp"
trap - RETURN
}
install_if_changed() {
local source="$1"
local destination="$2"
local mode="$3"
if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
return 1
fi
install -D -m "$mode" "$source" "$destination"
return 0
}
validate_broker_files_no_placeholders() {
local bad=0
local f
# authd-oidc 0.4.1 rejects any parsed config value containing both '<' and '>'.
# Scan the active main file and all drop-ins before attempting a restart.
if grep -nE '<[^>]+>' "$BROKER_CONF" >/dev/null 2>&1; then
printf 'ERROR: unresolved template placeholder(s) in %s:\n' "$BROKER_CONF" >&2
grep -nE '<[^>]+>' "$BROKER_CONF" >&2 || true
bad=1
fi
if [[ -d "${BROKER_CONF}.d" ]]; then
while IFS= read -r -d '' f; do
if grep -nE '<[^>]+>' "$f" >/dev/null 2>&1; then
printf 'ERROR: unresolved template placeholder(s) in drop-in %s:\n' "$f" >&2
grep -nE '<[^>]+>' "$f" >&2 || true
bad=1
fi
done < <(find "${BROKER_CONF}.d" -maxdepth 1 -type f -print0)
fi
(( bad == 0 )) || die "authd-oidc configuration contains unresolved <...> placeholders"
}
configure_broker_discovery() {
mkdir -p /etc/authd/brokers.d
if install_if_changed "$BROKER_DISCOVERY_SRC" "$BROKER_DISCOVERY_DST" 0644; then
log "Updated authd broker discovery: $BROKER_DISCOVERY_DST"
AUTHD_CHANGED=true
else
log "authd broker discovery already correct"
fi
}
configure_broker() {
local tmp
tmp="$(mktemp)"
trap 'rm -f "$tmp"' RETURN
{
printf '[oidc]\n'
printf 'issuer = %s\n' "$ISSUER"
printf 'client_id = %s\n' "$CLIENT_ID"
if [[ -n "$CLIENT_SECRET" ]]; then
printf 'client_secret = %s\n' "$CLIENT_SECRET"
fi
if [[ -n "$EXTRA_SCOPES" ]]; then
printf 'extra_scopes = %s\n' "$EXTRA_SCOPES"
fi
printf 'force_provider_authentication = %s\n' "$FORCE_PROVIDER_AUTHENTICATION"
printf '\n'
printf '[users]\n'
printf 'ssh_allowed_suffixes_first_auth = %s\n' "$SSH_SUFFIX"
printf 'allowed_users = %s\n' "$ALLOWED_USERS"
printf 'home_base_dir = %s\n' "$HOME_BASE_DIR"
if [[ -n "$EXTRA_GROUPS" ]]; then
printf 'extra_groups = %s\n' "$EXTRA_GROUPS"
fi
} >"$tmp"
# Preserve the snap-provided/generated file the first time we take control.
if [[ -f "$BROKER_CONF" && ! -e "${BROKER_CONF}.pre-${PROGRAM}" ]]; then
cp -a "$BROKER_CONF" "${BROKER_CONF}.pre-${PROGRAM}"
log "Saved original broker config as ${BROKER_CONF}.pre-${PROGRAM}"
fi
if install_if_changed "$tmp" "$BROKER_CONF" 0600; then
log "Updated broker configuration: $BROKER_CONF"
BROKER_CHANGED=true
else
log "Broker configuration already correct"
fi
rm -f "$tmp"
trap - RETURN
validate_broker_files_no_placeholders
}
configure_sshd() {
local tmp
tmp="$(mktemp)"
trap 'rm -f "$tmp"' RETURN
cat >"$tmp" <<EOF
# Managed by ${PROGRAM}
#
# Local/break-glass Unix accounts may still use SSH public keys.
# OIDC users matching ${SSH_SUFFIX} are forced through authd/PAM.
UsePAM yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
Match User *${SSH_SUFFIX}
PasswordAuthentication no
KbdInteractiveAuthentication yes
EOF
case "$SSH_POLICY" in
authd-only)
cat >>"$tmp" <<'EOF'
PubkeyAuthentication no
AuthenticationMethods keyboard-interactive:pam
EOF
;;
publickey+authd)
cat >>"$tmp" <<'EOF'
PubkeyAuthentication yes
AuthenticationMethods publickey,keyboard-interactive:pam
EOF
;;
esac
if install_if_changed "$tmp" "$SSHD_DROPIN" 0644; then
log "Updated SSH configuration: $SSHD_DROPIN"
SSH_CHANGED=true
else
log "SSH configuration already correct"
fi
rm -f "$tmp"
trap - RETURN
log "Validating sshd configuration"
sshd -t
}
restart_if_needed() {
if [[ "$AUTHD_CHANGED" == "true" ]]; then
log "Restarting authd"
systemctl restart authd
fi
if [[ "$BROKER_CHANGED" == "true" || "$AUTHD_CHANGED" == "true" ]]; then
log "Restarting authd-oidc broker"
if ! snap restart authd-oidc >/dev/null; then
printf 'ERROR: authd-oidc failed to restart. Recent broker log:\n' >&2
journalctl -u snap.authd-oidc.authd-oidc.service -b -n 60 --no-pager >&2 || true
if [[ -f "${BROKER_CONF}.pre-${PROGRAM}" ]]; then
printf 'ERROR: restoring previous broker configuration and retrying service start\n' >&2
cp -a "${BROKER_CONF}.pre-${PROGRAM}" "$BROKER_CONF"
systemctl reset-failed snap.authd-oidc.authd-oidc.service || true
snap restart authd-oidc >/dev/null || true
fi
die "authd-oidc broker restart failed; previous config was restored when available"
fi
fi
if [[ "$SSH_CHANGED" == "true" ]]; then
log "Restarting SSH"
systemctl restart ssh
fi
}
show_effective_config() {
local probe_user="authd-probe${SSH_SUFFIX}"
log "Service status"
systemctl is-active --quiet authd && echo "authd: active"
snap services authd-oidc
systemctl is-active --quiet ssh && echo "ssh: active"
log "Effective SSH authentication settings for ${probe_user}"
sshd -T -C "user=${probe_user},host=localhost,addr=127.0.0.1" 2>/dev/null \
| grep -E '^(usepam|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|authenticationmethods) ' \
|| true
log "Installed versions"
dpkg-query -W -f='authd: ${Version}\n' authd 2>/dev/null || true
snap list authd-oidc 2>/dev/null || true
cat <<EOF
Setup complete.
OIDC issuer: ${ISSUER}
OIDC client ID: ${CLIENT_ID}
SSH suffix: ${SSH_SUFFIX}
Allowed users: ${ALLOWED_USERS}
SSH policy: ${SSH_POLICY}
Test from a SECOND terminal while keeping your current administrative
session open:
ssh 'user${SSH_SUFFIX}'@<server>
For Keycloak MFA:
Configure the Keycloak authentication flow/client so Device Authorization
requires OTP/WebAuthn. This script configures the Ubuntu side only.
EOF
if [[ "$SSH_POLICY" == "publickey+authd" ]]; then
cat <<'EOF'
NOTE:
publickey+authd requires the SSH public key to succeed before authd runs.
For a brand-new JIT user, pre-provision the account/key or use centralized
SSH certificates / AuthorizedKeysCommand. Otherwise first login can fail.
EOF
fi
}
AUTHD_CHANGED=false
BROKER_CHANGED=false
SSH_CHANGED=false
install_packages
install_broker
validate_oidc
configure_broker_discovery
configure_broker
configure_sshd
restart_if_needed
show_effective_config