-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Comments
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): @martinpitt Perhaps we can address all these in one issue? I can try to fix more of these in my PR later. |
@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! |
@martinpitt Sure! I outlined the general concept in #21327 (comment), but will add more details to make it reproducable for a test case. My [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"
#!/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:
{
"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"
}
{
"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>"
}
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, Let me know if you need anything else! |
Explain what happens
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
The text was updated successfully, but these errors were encountered: