-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
robots: Add robot federation for keyless auth (PROJQUAY-7803) (quay#3207
) robots: Add robot federation for keyless auth (PROJQUAY-7652) adds the ability to configure federated auth for robots by using external OIDC providers. Each robot can be configured to have multiple external OIDC providers as the source for authentication.
- Loading branch information
Showing
30 changed files
with
1,191 additions
and
43 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
# Mock OIDC discovery and token endpoint data | ||
import datetime | ||
import json | ||
import uuid | ||
|
||
import jwt | ||
|
||
MOCK_DISCOVERY_RESPONSE = { | ||
"issuer": "https://mock-oidc-server.com", | ||
"authorization_endpoint": "https://mock-oidc-server.com/authorize", | ||
"token_endpoint": "https://mock-oidc-server.com/token", | ||
"jwks_uri": "https://mock-oidc-server.com/.well-known/jwks.json", | ||
"userinfo_endpoint": "https://mock-oidc-server.com/userinfo", | ||
"response_types_supported": ["code", "id_token", "token id_token"], | ||
"subject_types_supported": ["public"], | ||
"id_token_signing_alg_values_supported": ["RS256"], | ||
} | ||
|
||
MOCK_PRIVATE_KEY = """ | ||
-----BEGIN RSA PRIVATE KEY----- | ||
MIICXwIBAAKBgQDHd3NJdianKlLgzUmuc/fqYr/xFEDV7Ud3bPnO1N2r5UST7Rlj | ||
XkY2aEf6EL/4FvFZlKW/W6vwFelPMuAZGlZR717IABtj2YLpH8HnO53HqofezZHw | ||
QsahHwxmPJLXAl7Q4sdEg+/06bzsrFlYPWBftWpWKtUiPPK2KtmGdPFEEQIDAQAB | ||
AoGBAKIYNj36oAq04EkDSt9UKqH0wdqeBNpUSwGIM7GbVtD8LbCwuzL/R7urHuLe | ||
fcKUkmmj3NYXHzCp/cF4rJh5yK6317oim3MJjELYyY9K8eAZ2QRO/66JhphZqOD0 | ||
XJ6iYqxvX62vxqoixvlXDhWLm3Gtv/57dKGgy5jkjhZUYHphAkEA+haxmLvTKgDD | ||
9yDVOjv2iEPrn1IBDeYRrGcl4byZPzwXmtp7RuXtxdB1irtkoagdjySeYglIdOJ6 | ||
+EqKtP/bPQJBAMwucEeQYAHaIHFpYORaY+VlgCT97gcj08BHZByDm5YA0oQxIi+W | ||
jMz0NCdDT9eqUAGszZ6T5PvsOtnFvPOfKWUCQQDzujYuwa4UG1bge7ES5eln97mk | ||
NYktgHDs8kGq8+DuDaR7mD3YZLELvhMvt11lZrAYFvn8VUu2DhsF66+uokOJAkEA | ||
vw14/E2ouDLthpFvG11E+iJWnMaKUl4AxntGvrObAuo0EYOUFGlPyHt8zXxbmlZ/ | ||
1IFoSUjjy6KIkrtHCcLVTQJBAJB0NIhj1E8PdES5+s9XfqnMttK4V8lc46bb/3+U | ||
2H0hVBT7vR5sr+QjzEYSATW14c/9QBskZgsbtSEz6zf9+qU= | ||
-----END RSA PRIVATE KEY----- | ||
""" | ||
|
||
MOCK_PUBLIC_KEY = """ | ||
-----BEGIN PUBLIC KEY----- | ||
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHd3NJdianKlLgzUmuc/fqYr/x | ||
FEDV7Ud3bPnO1N2r5UST7RljXkY2aEf6EL/4FvFZlKW/W6vwFelPMuAZGlZR717I | ||
ABtj2YLpH8HnO53HqofezZHwQsahHwxmPJLXAl7Q4sdEg+/06bzsrFlYPWBftWpW | ||
KtUiPPK2KtmGdPFEEQIDAQAB | ||
-----END PUBLIC KEY----- | ||
""" | ||
|
||
MOCK_JWKS_RESPONSE = { | ||
"keys": [ | ||
{ | ||
"kty": "RSA", | ||
"n": "x3dzSXYmpypS4M1JrnP36mK_8RRA1e1Hd2z5ztTdq-VEk-0ZY15GNmhH-hC_-BbxWZSlv1ur8BXpTzLgGRpWUe9eyAAbY9mC6R_B5zudx6qH3s2R8ELGoR8MZjyS1wJe0OLHRIPv9Om87KxZWD1gX7VqVirVIjzytirZhnTxRBE", | ||
"e": "AQAB", | ||
"kid": "mock-key-id", | ||
} | ||
] | ||
} | ||
|
||
|
||
# Mock for discovery, JWKS, and token endpoints | ||
def mock_get(obj, url, *args, **kwargs): | ||
if url == "https://mock-oidc-server.com/.well-known/openid-configuration": | ||
return MockResponse(MOCK_DISCOVERY_RESPONSE, 200) | ||
elif url == "https://mock-oidc-server.com/.well-known/jwks.json": | ||
return MockResponse(MOCK_JWKS_RESPONSE, 200) | ||
return MockResponse({}, 404) | ||
|
||
|
||
def mock_request(obj, method, url, *args, **kwargs): | ||
return mock_get(None, url, *args, **kwargs) | ||
|
||
|
||
class MockResponse: | ||
def __init__(self, json_data, status_code): | ||
self.json_data = json_data | ||
self.status_code = status_code | ||
|
||
def json(self): | ||
return self.json_data | ||
|
||
@property | ||
def text(self): | ||
return json.dumps(self.json_data) | ||
|
||
|
||
def generate_mock_oidc_token( | ||
issuer="https://mock-oidc-server.com", | ||
subject="mock-subject", | ||
audience="mock-client-id", | ||
expiry_seconds=3600, | ||
issued_at=None, | ||
): | ||
now = datetime.datetime.now() | ||
iat = now - datetime.timedelta(seconds=30) | ||
if issued_at is not None: | ||
iat = issued_at | ||
|
||
exp = iat + datetime.timedelta(seconds=expiry_seconds) | ||
|
||
payload = { | ||
"iss": issuer, | ||
"sub": subject, | ||
"aud": audience, | ||
"exp": int(exp.timestamp()), | ||
"iat": int(iat.timestamp()), | ||
"nbf": int(iat.timestamp()), | ||
"nonce": str(uuid.uuid4()), | ||
"name": "Mock User", | ||
"preferred_username": "mockuser", | ||
"given_name": "Mock", | ||
"family_name": "User", | ||
"email": "[email protected]", | ||
"email_verified": True, | ||
} | ||
|
||
headers = {"kid": "mock-key-id"} | ||
|
||
return jwt.encode(payload, MOCK_PRIVATE_KEY, algorithm="RS256", headers=headers) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
import base64 | ||
import datetime | ||
from unittest.mock import patch | ||
|
||
import pytest | ||
import requests | ||
from jwt import DecodeError | ||
|
||
from auth.test.mock_oidc_server import generate_mock_oidc_token, mock_get, mock_request | ||
from auth.validateresult import AuthKind, ValidateResult | ||
from data import model | ||
from data.model import InvalidRobotCredentialException, InvalidRobotException | ||
from test.fixtures import * | ||
from util.security.federated_robot_auth import validate_federated_auth | ||
|
||
|
||
def test_validate_federated_robot_auth_bad_header(app): | ||
header = "Basic bad-basic-auth-header" | ||
result = validate_federated_auth(header) | ||
assert result == ValidateResult( | ||
AuthKind.federated, missing=True, error_message="Could not parse basic auth header" | ||
) | ||
|
||
|
||
def test_validate_federated_robot_auth_no_header(app): | ||
result = validate_federated_auth("") | ||
assert result == ValidateResult( | ||
AuthKind.federated, missing=True, error_message="No auth header" | ||
) | ||
|
||
|
||
def test_validate_federated_robot_auth_invalid_robot_name(app): | ||
creds = base64.b64encode("nonrobotuser:password".encode("utf-8")) | ||
header = f"Basic {creds.decode('utf-8')}" | ||
result = validate_federated_auth(header) | ||
assert result == ValidateResult(AuthKind.federated, missing=True, error_message="Invalid robot") | ||
|
||
|
||
def test_validate_federated_robot_auth_non_existing_robot(app): | ||
creds = base64.b64encode("someorg+somerobot:password".encode("utf-8")) | ||
header = f"Basic {creds.decode('utf-8')}" | ||
|
||
with pytest.raises(InvalidRobotException) as err: | ||
validate_federated_auth(header) | ||
|
||
assert "Could not find robot with specified username" in str(err) | ||
|
||
|
||
def test_validate_federated_robot_auth_invalid_jwt(app): | ||
robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) | ||
creds = base64.b64encode(f"{robot.username}:{password}".encode("utf-8")) | ||
header = f"Basic {creds.decode('utf-8')}" | ||
with pytest.raises(DecodeError) as e: | ||
validate_federated_auth(header) | ||
|
||
|
||
def test_validate_federated_robot_auth_no_fed_config(app): | ||
robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) | ||
token = generate_mock_oidc_token(subject=robot.username) | ||
creds = base64.b64encode(f"{robot.username}:{token}".encode("utf-8")) | ||
header = f"Basic {creds.decode('utf-8')}" | ||
with pytest.raises(InvalidRobotCredentialException) as e: | ||
result = validate_federated_auth(header) | ||
|
||
assert "Robot does not have federated login configured" in str(e) | ||
|
||
|
||
@patch.object(requests.Session, "request", mock_request) | ||
@patch.object(requests.Session, "get", mock_get) | ||
def test_validate_federated_robot_auth_expired_jwt(app): | ||
robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) | ||
fed_config = [ | ||
{ | ||
"issuer": "https://mock-oidc-server.com", | ||
"subject": robot.username, | ||
} | ||
] | ||
|
||
iat = datetime.datetime.now() - datetime.timedelta(seconds=4000) | ||
|
||
model.user.create_robot_federation_config(robot, fed_config) | ||
token = generate_mock_oidc_token(subject=robot.username, issued_at=iat) | ||
creds = base64.b64encode(f"{robot.username}:{token}".encode("utf-8")) | ||
header = f"Basic {creds.decode('utf-8')}" | ||
|
||
with pytest.raises(InvalidRobotCredentialException) as e: | ||
validate_federated_auth(header) | ||
assert "Signature has expired" in str(e) | ||
|
||
|
||
@patch.object(requests.Session, "request", mock_request) | ||
@patch.object(requests.Session, "get", mock_get) | ||
def test_validate_federated_robot_auth_valid_jwt(app): | ||
robot, password = model.user.create_robot("somerobot", model.user.get_user("devtable")) | ||
fed_config = [ | ||
{ | ||
"issuer": "https://mock-oidc-server.com", | ||
"subject": robot.username, | ||
} | ||
] | ||
model.user.create_robot_federation_config(robot, fed_config) | ||
|
||
token = generate_mock_oidc_token(subject=robot.username) | ||
creds = base64.b64encode(f"{robot.username}:{token}".encode("utf-8")) | ||
header = f"Basic {creds.decode('utf-8')}" | ||
|
||
result: ValidateResult = validate_federated_auth(header) | ||
assert result.error_message is None | ||
assert not result.missing | ||
assert result.kind == AuthKind.federated |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
data/migrations/versions/9085e82074f2_add_log_event_kind_for_federated_robot.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
"""Add log event kind for federated robot | ||
Revision ID: 9085e82074f2 | ||
Revises: a32e17bfad20 | ||
Create Date: 2024-09-09 15:49:24.911854 | ||
""" | ||
|
||
# revision identifiers, used by Alembic. | ||
revision = "9085e82074f2" | ||
down_revision = "ba263f9be4a6" | ||
|
||
import sqlalchemy as sa | ||
|
||
|
||
def upgrade(op, tables, tester): | ||
op.bulk_insert( | ||
tables.logentrykind, | ||
[ | ||
{"name": "create_robot_federation"}, | ||
{"name": "delete_robot_federation"}, | ||
{"name": "federated_robot_token_exchange"}, | ||
], | ||
) | ||
|
||
|
||
def downgrade(op, tables, tester): | ||
op.execute( | ||
tables.logentrykind.delete().where( | ||
tables.logentrykind.c.name | ||
== op.inline_literal("create_robot_federation") | tables.logentrykind.c.name | ||
== op.inline_literal("delete_robot_federation") | tables.logentrykind.c.name | ||
== op.inline_literal("federated_robot_token_exchange") | ||
) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,11 @@ | ||
from datetime import datetime | ||
from unittest.mock import Mock | ||
|
||
import pytest | ||
from mock import patch | ||
|
||
from auth.scopes import READ_REPO | ||
from auth.test.mock_oidc_server import MOCK_PUBLIC_KEY, generate_mock_oidc_token | ||
from data import model | ||
from data.database import DeletedNamespace, EmailConfirmation, FederatedLogin, User | ||
from data.fields import Credential | ||
|
@@ -46,6 +48,7 @@ | |
from data.queue import WorkQueue | ||
from test.fixtures import * | ||
from test.helpers import check_transitive_modifications | ||
from util.security.instancekeys import InstanceKeys | ||
from util.security.token import encode_public_private_token | ||
from util.timedeltastring import convert_to_timedelta | ||
|
||
|
@@ -332,13 +335,31 @@ def test_robot(initialized_db): | |
assert creds["username"] == "foobar+foo" | ||
assert creds["password"] == token | ||
|
||
assert verify_robot("foobar+foo", token) == robot | ||
assert verify_robot("foobar+foo", token, None) == robot | ||
|
||
with pytest.raises(InvalidRobotException): | ||
assert verify_robot("foobar+foo", "someothertoken") | ||
assert verify_robot("foobar+foo", "someothertoken", None) | ||
|
||
with pytest.raises(InvalidRobotException): | ||
assert verify_robot("foobar+unknownbot", token) | ||
assert verify_robot("foobar+unknownbot", token, None) | ||
|
||
|
||
def test_jwt_robot_token(initialized_db): | ||
user = get_user("devtable") | ||
org = create_organization("foobar", "[email protected]", user) | ||
create_robot("foo", org) | ||
|
||
mock_oidc_token = generate_mock_oidc_token( | ||
issuer="quay", audience="quay-aud", subject="foobar+foo" | ||
) | ||
robot, token = create_robot("foo", user) | ||
|
||
mock_instance_keys = Mock(InstanceKeys) | ||
mock_instance_keys.service_name = "quay" | ||
mock_instance_keys.get_service_key_public_key = Mock(return_value=MOCK_PUBLIC_KEY) | ||
|
||
with patch("data.model.config.app_config", {"SERVER_HOSTNAME": "quay-aud"}): | ||
verify_robot("foobar+foo", mock_oidc_token, mock_instance_keys) | ||
|
||
|
||
def test_get_estimated_robot_count(initialized_db): | ||
|
Oops, something went wrong.