Skip to content

Commit

Permalink
robots: Add robot federation for keyless auth (PROJQUAY-7803) (quay#3207
Browse files Browse the repository at this point in the history
)

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
syed authored Sep 24, 2024
1 parent 3181dfc commit e9161cb
Show file tree
Hide file tree
Showing 30 changed files with 1,191 additions and 43 deletions.
1 change: 0 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,6 @@ def _request_end(resp):
sentry = Sentry(app)
build_logs = BuildLogs(app)
userevents = UserEventsBuilderModule(app)
instance_keys = InstanceKeys(app)
label_validator = LabelValidator(app)
build_canceller = BuildCanceller(app)

Expand Down
10 changes: 5 additions & 5 deletions auth/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
from enum import Enum

from flask import request
from jwt import InvalidTokenError

import features
from app import app, authentication
from app import app, authentication, instance_keys
from auth.credential_consts import (
ACCESS_TOKEN_USERNAME,
APP_SPECIFIC_TOKEN_USERNAME,
Expand Down Expand Up @@ -119,10 +120,9 @@ def validate_credentials(auth_username, auth_password_or_token):
if is_robot:
logger.debug("Found credentials header for robot %s", auth_username)
try:
robot = model.user.verify_robot(auth_username, auth_password_or_token)

robot = model.user.verify_robot(auth_username, auth_password_or_token, instance_keys)
assert robot
logger.debug("Successfully validated credentials for robot %s", auth_username)

return ValidateResult(AuthKind.credentials, robot=robot), CredentialKind.robot
except model.DeactivatedRobotOwnerException as dre:
robot_owner, robot_name = parse_robot_username(auth_username)
Expand Down Expand Up @@ -155,7 +155,7 @@ def validate_credentials(auth_username, auth_password_or_token):
ValidateResult(AuthKind.credentials, error_message=str(dre)),
CredentialKind.robot,
)
except model.InvalidRobotCredentialException as ire:
except (model.InvalidRobotCredentialException, InvalidTokenError) as ire:
logger.debug("Failed to validate credentials for robot %s: %s", auth_username, ire)

if app.config.get("ACTION_LOG_AUDIT_LOGIN_FAILURES"):
Expand Down
2 changes: 2 additions & 0 deletions auth/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from auth.signedgrant import validate_signed_grant
from auth.validateresult import AuthKind
from util.http import abort
from util.security.federated_robot_auth import validate_federated_auth

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -77,6 +78,7 @@ def wrapper(*args, **kwargs):
process_auth_or_cookie = _auth_decorator(handlers=[validate_basic_auth, validate_session_cookie])
process_basic_auth = _auth_decorator(handlers=[validate_basic_auth], pass_result=True)
process_basic_auth_no_pass = _auth_decorator(handlers=[validate_basic_auth])
process_federated_auth = _auth_decorator(handlers=[validate_federated_auth], pass_result=True)


def require_session_login(func):
Expand Down
2 changes: 1 addition & 1 deletion auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
_conduct_oauth_login,
get_jwt_issuer,
get_sub_username_email_from_token,
is_jwt,
)
from oauth.oidc import PublicKeyLoadException
from util.security.jwtutil import is_jwt

logger = logging.getLogger(__name__)

Expand Down
116 changes: 116 additions & 0 deletions auth/test/mock_oidc_server.py
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)
110 changes: 110 additions & 0 deletions auth/test/test_federated_robot_auth.py
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
1 change: 1 addition & 0 deletions auth/validateresult.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class AuthKind(Enum):
signed_grant = "signed_grant"
credentials = "credentials"
ssojwt = "ssojwt"
federated = "federated"

def __str__(self):
return "%s" % self.value
Expand Down
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")
)
)
27 changes: 24 additions & 3 deletions data/model/test/test_user.py
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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down
Loading

0 comments on commit e9161cb

Please sign in to comment.