Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Loading spinner for OAuth / Bearer token validation broken #21325

Closed
marvinruder opened this issue Nov 25, 2024 · 4 comments · Fixed by #21326
Closed

Loading spinner for OAuth / Bearer token validation broken #21325

marvinruder opened this issue Nov 25, 2024 · 4 comments · Fixed by #21326
Labels

Comments

@marvinruder
Copy link
Contributor

Explain what happens

  1. Configure Cockpit to use OAuth and Bearer tokens to log in
  2. Open Cockpit, get redirected to the OAuth provider and back to Cockpit
  3. While waiting for token validation, observe this odd icon which is probably supposed to be a loading spinner:

Screenshot 2024-11-25 at 9 30 52 PM

Version of Cockpit

329

Where is the problem in Cockpit?

None

Server operating system

Fedora

Server operating system version

Fedora CoreOS 41.20241027.3.0

What browsers are you using?

Chrome, Safari macOS, Safari on iPhone

System log

No response

@marvinruder
Copy link
Contributor Author

Another visual glitch I discovered related to OAuth user interfaces:

Since this button is actually a link, it inherits the color from a nodes and therefore looks a bit faded with the dark theme.

Screenshot 2024-11-25 at 11 08 08 PM

@marvinruder
Copy link
Contributor Author

I seem to find more issues related to UI elements that are only visible with OAuth authentication. When not using Dark theme, this is the loading spinner with the “Validating authentication token” text highlighted (yes, this is white text and white loading spinner on white background):

Screenshot 2024-11-26 at 8 58 31 AM

@martinpitt Perhaps we can address all these in one issue? I can try to fix more of these in my PR later.

@martinpitt
Copy link
Member

@marvinruder Thanks for looking into this! Admittedly oauth is completely untested in cockpit. If we could do that, we could also introduce "pixel tests" (i.e. making sure that the login page looks exactly as intended) to avoid breaking this in the future.

Would you by any chance be willing to write down how you set up oauth? Does the server end fit into a container or at least a small VM? And how to configure the client side?

Sorry, our team is currently very small, so any help to kickstart this is much appreciated!

@marvinruder
Copy link
Contributor Author

marvinruder commented Nov 28, 2024

Would you by any chance be willing to write down how you set up oauth? Does the server end fit into a container or at least a small VM? And how to configure the client side?

@martinpitt Sure! I outlined the general concept in #21327 (comment), but will add more details to make it reproducable for a test case.

My cockpit.conf file contains the following (with unrelated options omitted):

[Bearer]
Command = /container/cockpit-auth-bearer

[OAuth]
URL = https://sso.<domain>/realms/<realm>/protocol/openid-connect/auth?client_id=cockpit&response_type=token

Relevant environment variables are:

COCKPIT_OAUTH_HOST: host.docker.internal
COCKPIT_SSH_KEY_PATH: "/etc/cockpit/id_ed25519"

cockpit-auth-bearer is a Python script, essentially a modified /container/cockpit-auth-ssh-key:

#!/usr/bin/python3
# This file is part of Cockpit.
#
# Copyright (C) 2018 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <https://www.gnu.org/licenses/>.

# This command is meant to be used as an authentication command launched by
# cockpit-ws. It asks for authentication from cockpit-ws and expects to
# receive a basic auth header in response. If COCKPIT_SSH_KEY_PATH or
# COCKPIT_SSH_KEY_PATH_{HOSTNAME} is set, we will try to decrypt the key with
# the given password. If successful we send the decrypted key to cockpit-ws
# for use with ssh. Once finished we exec cockpit.beiboot to actually
# establish the ssh connection. All communication with cockpit-ws happens on
# stdin and stdout using the cockpit protocol
# (https://github.com/cockpit-project/cockpit/blob/main/doc/protocol.md)

import base64
import json
import os
import subprocess
import sys
import time

import subprocess
import sys


def usage():
    print("usage", sys.argv[0], "[user@]host[:port]", file=sys.stderr)
    sys.exit(os.EX_USAGE)


def send_frame(content):
    data = json.dumps(content).encode()
    os.write(1, str(len(data) + 1).encode())
    os.write(1, b"\n\n")
    os.write(1, data)
    os.write(2, data)


def send_auth_command(challenge, response):
    cmd = {
        "command": "authorize",
    }

    if challenge:
        cmd["cookie"] = f"session{os.getpid()}{time.time()}"
        cmd["challenge"] = challenge
    if response:
        cmd["response"] = response

    send_frame(cmd)


def send_problem_init(problem, message, auth_methods):
    cmd = {
        "command": "init",
        "problem": problem
    }

    if message:
        cmd["message"] = message

    if auth_methods:
        cmd["auth-method-results"] = auth_methods

    send_frame(cmd)


def read_size(fd):
    sep = b'\n'
    size = 0
    seen = 0

    while True:
        t = os.read(fd, 1)

        if not t:
            return 0

        if t == sep:
            break

        size = (size * 10) + int(t)
        seen = seen + 1

        if seen > 7:
            raise ValueError("Invalid frame: size too long")

    return size


def read_frame(fd):
    size = read_size(fd)

    data = b""
    while size > 0:
        d = os.read(fd, size)
        size = size - len(d)
        data += d

    return data.decode()


def read_auth_reply():
    data = read_frame(1)
    cmd = json.loads(data)
    response = cmd.get("response")
    if cmd.get("command") != "authorize" or \
       not cmd.get("cookie") or not response:
        raise ValueError("Did not receive a valid authorize command")

    return response


def decode_bearer_header(response):
    starts = "Bearer "

    assert response
    assert response.startswith(starts), response

    jwt_token = response[len(starts):]
    jwt_token_payload = jwt_token.split('.')[1]
    access_token = json.loads(base64.urlsafe_b64decode(jwt_token_payload + '=' * (4 - len(jwt_token_payload) % 4)).decode())
    credentials = list(access_token["resource_access"]["cockpit"]["roles"].values())[0]["credentials"]
    if type(credentials) is list:
        credentials = credentials[0]
    user, password = base64.b64decode(credentials).decode().split(':', 1)
    return user, password


def load_key(fname, password):
    # ssh-add has the annoying behavior that it re-asks without any limit if the password is wrong
    # to mitigate, self-destruct to only allow one iteration
    with open("/run/askpass", "w") as fd:
        fd.write("""#!/bin/sh
rm -f $0
cat /run/password""")

        os.fchmod(fd.fileno(), 0o755)

    env = os.environ.copy()
    env["SSH_ASKPASS_REQUIRE"] = "force"
    env["SSH_ASKPASS"] = "/run/askpass"

    pass_fd = os.open("/run/password", os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_CLOEXEC, mode=0o600)
    try:
        os.write(pass_fd, password.encode())
        os.close(pass_fd)

        p = subprocess.run(["ssh-add", "-t", "30", fname],
                           check=False, env=env,
                           capture_output=True, encoding="UTF-8")
    finally:
        os.unlink("/run/password")

    if p.returncode == 0:
        send_auth_command(None, "ssh-agent")
        return True
    else:
        print("Couldn't load private key:", p.stderr, file=sys.stderr)
        return False


def main(args):
    if len(args) != 2:
        usage()

    host = os.environ.get("COCKPIT_OAUTH_HOST")
    key_env = f"COCKPIT_SSH_KEY_PATH_{host.upper()}"
    key_name = os.environ.get(key_env) or os.environ.get("COCKPIT_SSH_KEY_PATH")
    if key_name:
        send_auth_command("*", None)
        try:
            resp = read_auth_reply()
            user, password = decode_bearer_header(resp)
        except (ValueError, TypeError, AssertionError) as e:
            send_problem_init("internal-error", str(e), {})
            raise

        if load_key(key_name, password):
            host = f"{user}@{host}"
        else:
            send_problem_init("authentication-failed", "Couldn't open private key",
                              {"password": "denied"})
            return

    os.execlpe("python3", "python3", "-m", "cockpit.beiboot", host, os.environ)


if __name__ == '__main__':
    main(sys.argv)

My OAuth provider is a selfhosted Keycloak instance, but for a test one could probably use a simple webserver that redirects back to Cockpit with a static access token.

The login flow is as follows:

  1. A user opens https://cockpit.internal.<domain>. The environment variable in it contains the following configuration:
{
  "is_cockpit_client":false,
  "page":{
    "title":"<domain>",
    "connect":true,
    "require_host":true,
    "allow_multihost":true
  },
  "logged_into":[],
  "hostname":"cockpit-ws",
  "os-release":{
    "NAME":"Fedora Linux",
    "ID":"fedora",
    "PRETTY_NAME":"Fedora CoreOS 41.20241109.3.0",
    "VARIANT":"CoreOS",
    "VARIANT_ID":"coreos",
    "CPE_NAME":"cpe:/o:fedoraproject:fedora:41",
    "DOCUMENTATION_URL":"https://docs.fedoraproject.org/en-US/fedora-coreos/"
  },
  "OAuth":{
    "URL":"https://sso.<domain>/realms/<realm>/protocol/openid-connect/auth?client_id=cockpit&response_type=token",
    "ErrorParam":null,
    "TokenParam":null
  },
  "CACertUrl":"/ca.cer"
}
  1. The Cockpit frontend redirects the user to https://sso.<domain>/realms/<realm>/protocol/openid-connect/auth?client_id=cockpit&response_type=token&redirect_uri=https%3A%2F%2Fcockpit.internal.<domain>%2F
  2. The OAuth provider authenticates the user and redirects the user back to https://cockpit.internal.<domain>/#session_state=<session id>&iss=https%3A%2F%2Fsso.<domain>%2Frealms%2F<realm>&access_token=<access token>&token_type=Bearer&expires_in=900. The access token is a JWT token with the following payload:
{
  "exp": 1732822245,
  "iat": 1732821345,
  "jti": "<jwt id>",
  "iss": "https://sso.<domain>/realms/<realm>",
  "typ": "Bearer",
  "azp": "cockpit",
  "sid": "<session id>",
  "scope": "email profile",
  "resource_access": {
    "cockpit": {
      "roles": {
        "user": {
          "credentials": [
            "dXNlcjpwYXNz"
          ]
        }
      }
    }
  },
  "email_verified": true,
  "name": "Marvin Ruder",
  "preferred_username": "mruder",
  "given_name": "Marvin",
  "family_name": "Ruder",
  "email": "webmaster@<domain>"
}
  1. The Cockpit backend executes the python script by calling /container/cockpit-auth-bearer localhost (Why localhost? I am complaining about this in Allow choosing host when OAuth / Bearer token authentication is enabled #21327) and provides the acces token as a Bearer token to it.
  2. The script decodes the token payload and evaluates the credentials of the first (should always be the only) role in resource_access.cockpit.roles. A custom role mapping script for Keycloak encoded the role’s key-value pairs into the access token at this location. From there, the credential dXNlcjpwYXNz is decoded to user:pass.
  3. The script will attempt to login to COCKPIT_OAUTH_HOST via SSH using the username user from the credential and will decrypt the key supplied at COCKPIT_SSH_KEY_PATH with the password pass from the credential.
  4. If this succeeds, a session is created and the Cockpit server management interface is presented to the user. The user is then free to connect to other hosts using the host switcher, but this has nothing to do with OAuth anymore.

This is a setup with heavy customization of both Cockpit and Keycloak, but for the moment, it works great for me. As you can see, the server that I log into is a regular SSH server without anything OAuth-related. The SSH login credentials are taken from the access token and maintained as a client role attribute within Keycloak.

I also do not validate the signature of the access token in my script yet (I would need additional Python packages for that, cockpit-ws does not contain pip and I was too lazy to create a custom container), but since my Cockpit server is accessible only via VPN and the access token must contain the secret SSH key password anyway, I did not focus on that topic for now.

Let me know if you need anything else!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants