Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions nmostesting/IS10Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
from Crypto.PublicKey import RSA
from authlib.jose import jwt, JsonWebKey

import re
import time
import uuid

from .NMOSUtils import NMOSUtils
from OpenSSL import crypto
from cryptography.hazmat.primitives import serialization
from cryptography import x509
from flask import request
from .TestHelper import get_default_ip, get_mocks_hostname

from . import Config as CONFIG
Expand Down Expand Up @@ -157,3 +159,36 @@ def is_any_contain(list, enum):
if item in [e.name for e in enum]:
return True
return False

@staticmethod
def check_authorization(auth, path, scope="x-nmos-registration", write=False):
def _check_path_match(path, path_wildcards):
path_match = False
for path_wildcard in path_wildcards:
pattern = path_wildcard.replace("*", ".*")
if re.search(pattern, path):
path_match = True
break
return path_match

if CONFIG.ENABLE_AUTH:
try:
if "Authorization" not in request.headers:
return 400, "Authorization header not found"
if not request.headers["Authorization"].startswith("Bearer "):
return 400, "Bearer not found in Authorization header"
token = request.headers["Authorization"].split(" ")[1]
claims = jwt.decode(token, auth.generate_jwk())
claims.validate()
if claims["iss"] != auth.make_issuer():
return 401, f"Unexpected issuer, expected: {auth.make_issuer()}, actual: {claims['iss']}"
# TODO: Check 'aud' claim matches 'mocks.<domain>'
if not _check_path_match(path, claims[scope]["read"]):
return 403, f"Paths mismatch for {scope} read claims"
if write and not _check_path_match(path, claims[scope]["write"]):
return 403, f"Paths mismatch for {scope} write claims"
except KeyError as err:
return 400, f"KeyError: {err}"
except Exception as err:
return 400, f"Exception: {err}"
return True, ""
1 change: 1 addition & 0 deletions nmostesting/NMOSTesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@
# Primary Authorization server
if CONFIG.ENABLE_AUTH:
auth_app = Flask(__name__)
CORS(auth_app)
auth_app.debug = False
auth_app.config['AUTH_INSTANCE'] = 0
auth_app.config['PORT'] = PRIMARY_AUTH.port
Expand Down
20 changes: 10 additions & 10 deletions nmostesting/mocks/Auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def __init__(self, port_increment, version="v1.0"):
self.host = get_mocks_hostname()
# authorization code of the authorization code flow
self.code = None
self.scopes_cache = {} # remember client scopes

def make_mdns_info(self, priority=0, api_ver=None, ip=None):
"""Get an mDNS ServiceInfo object in order to create an advertisement"""
Expand Down Expand Up @@ -302,10 +303,6 @@ def auth_auth():
# Recommended parameters
# state

ctype_valid, ctype_message = check_content_type(request.headers, "application/x-www-form-urlencoded")
if not ctype_valid:
raise AuthException("invalid_request", ctype_message)

# hmm, no client authorization done, just redirects a random authorization code back to the client
# TODO: add web pages for client authorization for the future

Expand Down Expand Up @@ -342,6 +339,8 @@ def auth_auth():
if not scope_found:
error = "invalid_request"
error_description = "scope: {} are not supported".format(scopes)
# cache the client scopes
auth.scopes_cache[request.args["client_id"]] = scopes

vars = {}
if error:
Expand Down Expand Up @@ -370,7 +369,6 @@ def auth_auth():
def auth_token():
auth = AUTHS[flask.current_app.config["AUTH_INSTANCE"]]
try:
auth_header_required = False
scopes = []

ctype_valid, ctype_message = check_content_type(request.headers, "application/x-www-form-urlencoded")
Expand All @@ -395,7 +393,13 @@ def auth_token():

refresh_token = query["refresh_token"][0] if "refresh_token" in query else None

scopes = query["scope"][0].split() if "scope" in query else SCOPE.split() if SCOPE else []
# Scope query parameter is OPTIONAL
# see https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.2
# and https://datatracker.ietf.org/doc/html/rfc6749#section-6
# Use scopes cached from when the token was created if not provided in query
cached_scopes = auth.scopes_cache[client_id] if client_id in auth.scopes_cache else []
scopes = query["scope"][0].split() if "scope" in query else cached_scopes \
if len(cached_scopes) else SCOPE.split() if SCOPE else []
if scopes:
scope_found = IS10Utils.is_any_contain(scopes, SCOPES)
if not scope_found:
Expand Down Expand Up @@ -484,8 +488,6 @@ def auth_token():
else:
raise AuthException("unsupported_grant_type",
"missing client_assertion_type used for private_key_jwt client authentication")
else:
auth_header_required = True

# for the Confidential client, client_id and client_secret are embedded in the Authorization header
auth_header = request.headers.get("Authorization", None)
Expand All @@ -504,8 +506,6 @@ def auth_token():
"missing client_id or client_secret from authorization header")
else:
raise AuthException("invalid_client", "invalid authorization header")
elif auth_header_required:
raise AuthException("invalid_client", "invalid authorization header", HTTPStatus.UNAUTHORIZED)

# client_id MUST be provided by all types of client
if not client_id:
Expand Down
83 changes: 83 additions & 0 deletions nmostesting/mocks/Node.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from .. import Config as CONFIG
from ..TestHelper import get_default_ip, do_request
from ..IS04Utils import IS04Utils
from ..IS10Utils import IS10Utils
from .Auth import PRIMARY_AUTH


class Node(object):
Expand All @@ -39,6 +41,7 @@ def reset(self):
self.receivers = {}
self.senders = {}
self.patched_sdp = {}
self.auth_cache = {}

def get_sender(self, media_type="video/raw", version="v1.3"):
protocol = "http"
Expand Down Expand Up @@ -360,6 +363,27 @@ def patch_staged(self, resource, resource_id, request_json):

return response_data, response_code

def check_authorization(self, auth, path, scope, write=False):
if not CONFIG.ENABLE_AUTH:
return True, ""

if "Authorization" in request.headers and request.headers["Authorization"].startswith("Bearer ") \
and scope in self.auth_cache and \
((write and self.auth_cache[scope]["Write"]) or self.auth_cache[scope]["Read"]):
return True, ""

authorized, error_message = IS10Utils.check_authorization(auth,
path,
scope=scope,
write=write)
if authorized:
if scope not in self.auth_cache:
self.auth_cache[scope] = {"Read": True, "Write": write}
else:
self.auth_cache[scope]["Read"] = True
self.auth_cache[scope]["Write"] = self.auth_cache[scope]["Write"] or write
return authorized, error_message


NODE = Node(1)
NODE_API = Blueprint('node_api', __name__)
Expand All @@ -376,25 +400,49 @@ def x_nmos_root():
def connection_root():
base_data = ['v1.0/', 'v1.1/']

authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)

return make_response(Response(json.dumps(base_data), mimetype='application/json'))


@NODE_API.route('/x-nmos/connection/<version>', methods=['GET'], strict_slashes=False)
def version(version):
base_data = ['bulk/', 'single/']

authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)

return make_response(Response(json.dumps(base_data), mimetype='application/json'))


@NODE_API.route('/x-nmos/connection/<version>/single', methods=['GET'], strict_slashes=False)
def single(version):
base_data = ['senders/', 'receivers/']

authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)

return make_response(Response(json.dumps(base_data), mimetype='application/json'))


@NODE_API.route('/x-nmos/connection/<version>/single/<resource>/', methods=["GET"], strict_slashes=False)
def resources(version, resource):
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)

if resource == 'senders':
base_data = [r + '/' for r in [*NODE.senders]]
elif resource == 'receivers':
Expand All @@ -405,6 +453,12 @@ def resources(version, resource):

@NODE_API.route('/x-nmos/connection/<version>/single/<resource>/<resource_id>', methods=["GET"], strict_slashes=False)
def connection(version, resource, resource_id):
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)

if resource != 'senders' and resource != 'receivers':
abort(404)

Expand Down Expand Up @@ -441,6 +495,12 @@ def _get_constraints(resource):
@NODE_API.route('/x-nmos/connection/<version>/single/<resource>/<resource_id>/constraints',
methods=["GET"], strict_slashes=False)
def constraints(version, resource, resource_id):
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)

base_data = [_get_constraints(resource)]

return make_response(Response(json.dumps(base_data), mimetype='application/json'))
Expand Down Expand Up @@ -479,6 +539,14 @@ def staged(version, resource, resource_id):
activating a connection without staging or deactivating an active connection
Updates data then POSTs updated resource to registry
"""
write = (request.method == 'PATCH')
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection",
write=write)
if authorized is not True:
abort(authorized, description=error_message)

# Track requests
NODE.staged_requests.append({'method': request.method, 'resource': resource, 'resource_id': resource_id,
'data': request.get_json(silent=True)})
Expand Down Expand Up @@ -516,6 +584,11 @@ def staged(version, resource, resource_id):
@NODE_API.route('/x-nmos/connection/<version>/single/<resource>/<resource_id>/active',
methods=["GET"], strict_slashes=False)
def active(version, resource, resource_id):
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)
try:
if resource == 'senders':
base_data = NODE.senders[resource_id]['activations']['active']
Expand All @@ -530,6 +603,11 @@ def active(version, resource, resource_id):
@NODE_API.route('/x-nmos/connection/<version>/single/<resource>/<resource_id>/transporttype',
methods=["GET"], strict_slashes=False)
def transport_type(version, resource, resource_id):
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)
# TODO fetch from resource info
base_data = "urn:x-nmos:transport:rtp"

Expand Down Expand Up @@ -584,6 +662,11 @@ def node_sdp(media_type, media_subtype):
@NODE_API.route('/x-nmos/connection/<version>/single/<resource>/<resource_id>/transportfile',
methods=["GET"], strict_slashes=False)
def transport_file(version, resource, resource_id):
authorized, error_message = NODE.check_authorization(PRIMARY_AUTH,
request.path,
scope="x-nmos-connection")
if authorized is not True:
abort(authorized, description=error_message)
# GET should either redirect to the location of the transport file or return it directly
try:
if resource == 'senders':
Expand Down
Loading