diff --git a/docs/source/api.rst b/docs/source/api.rst index 2e934a2b5..8cace8da5 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -116,7 +116,7 @@ Example: import neo4j - auth = neo4j.Auth(scheme="basic", principal="neo4j", credentials="password") + auth = neo4j.Auth("basic", "neo4j", "password") Auth Token Helper Functions @@ -128,6 +128,8 @@ Alternatively, one of the auth token helper functions can be used. .. autofunction:: neo4j.kerberos_auth +.. autofunction:: neo4j.bearer_auth + .. autofunction:: neo4j.custom_auth diff --git a/neo4j/__init__.py b/neo4j/__init__.py index 9aa75c0a3..950bf6fa8 100644 --- a/neo4j/__init__.py +++ b/neo4j/__init__.py @@ -29,6 +29,7 @@ "AuthToken", "basic_auth", "kerberos_auth", + "bearer_auth", "custom_auth", "Bookmark", "ServerInfo", @@ -69,6 +70,7 @@ AuthToken, basic_auth, kerberos_auth, + bearer_auth, custom_auth, Bookmark, ServerInfo, diff --git a/neo4j/api.py b/neo4j/api.py index 8ed2ce265..1275c2a33 100644 --- a/neo4j/api.py +++ b/neo4j/api.py @@ -61,27 +61,30 @@ # TODO: This class is not tested class Auth: - """ Container for auth details. + """Container for auth details. - :param scheme: specifies the type of authentication, examples: "basic", "kerberos" + :param scheme: specifies the type of authentication, examples: "basic", + "kerberos" :type scheme: str :param principal: specifies who is being authenticated - :type principal: str + :type principal: str or None :param credentials: authenticates the principal - :type credentials: str + :type credentials: str or None :param realm: specifies the authentication provider - :type realm: str - :param parameters: extra key word parameters passed along to the authentication provider - :type parameters: str + :type realm: str or None + :param parameters: extra key word parameters passed along to the + authentication provider + :type parameters: Dict[str, Any] """ - #: By default we should not send any realm - realm = None - def __init__(self, scheme, principal, credentials, realm=None, **parameters): self.scheme = scheme - self.principal = principal - self.credentials = credentials + # Neo4j servers pre 4.4 require the principal field to always be + # present. Therefore, we transmit it even if it's an empty sting. + if principal is not None: + self.principal = principal + if credentials: + self.credentials = credentials if realm: self.realm = realm if parameters: @@ -93,13 +96,16 @@ def __init__(self, scheme, principal, credentials, realm=None, **parameters): def basic_auth(user, password, realm=None): - """ Generate a basic auth token for a given user and password. + """Generate a basic auth token for a given user and password. This will set the scheme to "basic" for the auth token. - :param user: user name, this will set the principal + :param user: user name, this will set the + :type user: str :param password: current password, this will set the credentials + :type password: str :param realm: specifies the authentication provider + :type realm: str or None :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` @@ -108,11 +114,13 @@ def basic_auth(user, password, realm=None): def kerberos_auth(base64_encoded_ticket): - """ Generate a kerberos auth token with the base64 encoded ticket + """Generate a kerberos auth token with the base64 encoded ticket. This will set the scheme to "kerberos" for the auth token. - :param base64_encoded_ticket: a base64 encoded service ticket, this will set the credentials + :param base64_encoded_ticket: a base64 encoded service ticket, this will set + the credentials + :type base64_encoded_ticket: str :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` @@ -120,14 +128,35 @@ def kerberos_auth(base64_encoded_ticket): return Auth("kerberos", "", base64_encoded_ticket) +def bearer_auth(base64_encoded_token): + """Generate an auth token for Single-Sign-On providers. + + This will set the scheme to "bearer" for the auth token. + + :param base64_encoded_token: a base64 encoded authentication token generated + by a Single-Sign-On provider. + :type base64_encoded_token: str + + :return: auth token for use with :meth:`GraphDatabase.driver` + :rtype: :class:`neo4j.Auth` + """ + return Auth("bearer", None, base64_encoded_token) + + def custom_auth(principal, credentials, realm, scheme, **parameters): - """ Generate a custom auth token. + """Generate a custom auth token. :param principal: specifies who is being authenticated + :type principal: str or None :param credentials: authenticates the principal + :type credentials: str or None :param realm: specifies the authentication provider + :type realm: str or None :param scheme: specifies the type of authentication - :param parameters: extra key word parameters passed along to the authentication provider + :type scheme: str or None + :param parameters: extra key word parameters passed along to the + authentication provider + :type parameters: Dict[str, Any] :return: auth token for use with :meth:`GraphDatabase.driver` :rtype: :class:`neo4j.Auth` diff --git a/neo4j/exceptions.py b/neo4j/exceptions.py index d8541a31a..efd3174bd 100644 --- a/neo4j/exceptions.py +++ b/neo4j/exceptions.py @@ -30,6 +30,7 @@ + CypherTypeError + ConstraintError + AuthError + + TokenExpired + Forbidden + DatabaseError + TransientError @@ -199,6 +200,12 @@ class AuthError(ClientError): """ +class TokenExpired(AuthError): + """ Raised when the authentication token has expired. + + A new driver instance with a fresh authentication token needs to be created. + """ + client_errors = { # ConstraintError @@ -228,8 +235,11 @@ class AuthError(ClientError): "Neo.ClientError.Security.AuthorizationFailed": AuthError, "Neo.ClientError.Security.Unauthorized": AuthError, + # TokenExpired + "Neo.ClientError.Security.TokenExpired": TokenExpired, + # NotALeader - "Neo.ClientError.Cluster.NotALeader": NotALeader + "Neo.ClientError.Cluster.NotALeader": NotALeader, } transient_errors = { diff --git a/testkitbackend/requests.py b/testkitbackend/requests.py index 070996564..70036e822 100644 --- a/testkitbackend/requests.py +++ b/testkitbackend/requests.py @@ -54,10 +54,23 @@ def NewDriver(backend, data): data["authorizationToken"].mark_item_as_read_if_equals( "name", "AuthorizationToken" ) - auth = neo4j.Auth( - auth_token["scheme"], auth_token["principal"], - auth_token["credentials"], realm=auth_token["realm"]) - auth_token.mark_item_as_read_if_equals("ticket", "") + scheme = auth_token["scheme"] + if scheme == "basic": + auth = neo4j.basic_auth( + auth_token["principal"], auth_token["credentials"], + realm=auth_token.get("realm", None) + ) + elif scheme == "kerberos": + auth = neo4j.kerberos_auth(auth_token["credentials"]) + elif scheme == "bearer": + auth = neo4j.bearer_auth(auth_token["credentials"]) + else: + auth = neo4j.custom_auth( + auth_token["principal"], auth_token["credentials"], + auth_token["realm"], auth_token["scheme"], + **auth_token.get("parameters", {}) + ) + auth_token.mark_item_as_read("parameters", recursive=True) resolver = None if data["resolverRegistered"] or data["domainNameResolverRegistered"]: resolver = resolution_func(backend, data["resolverRegistered"], diff --git a/testkitbackend/test_config.json b/testkitbackend/test_config.json index ebe59eae9..47b97e7a4 100644 --- a/testkitbackend/test_config.json +++ b/testkitbackend/test_config.json @@ -32,6 +32,12 @@ "Flaky: test requires the driver to contact servers in a specific order", "stub.authorization.test_authorization.TestAuthorizationV4x1.test_should_retry_on_auth_expired_on_begin_using_tx_function": "Flaky: test requires the driver to contact servers in a specific order", + "stub.authorization.test_authorization.TestAuthorizationV4x3.test_should_fail_on_token_expired_on_begin_using_tx_function": + "Flaky: test requires the driver to contact servers in a specific order", + "stub.authorization.test_authorization.TestAuthorizationV3.test_should_fail_on_token_expired_on_begin_using_tx_function": + "Flaky: test requires the driver to contact servers in a specific order", + "stub.authorization.test_authorization.TestAuthorizationV4x1.test_should_fail_on_token_expired_on_begin_using_tx_function": + "Flaky: test requires the driver to contact servers in a specific order", "stub.session_run_parameters.test_session_run_parameters.TestSessionRunParameters.test_empty_query": "Driver rejects empty queries before sending it to the server", "tls.tlsversions.TestTlsVersions.test_1_1": @@ -40,6 +46,9 @@ "features": { "Feature:API:Result.Single": "Does not raise error when not exactly one record is available. To be fixed in 5.0", "Feature:API:Result.Peek": true, + "Feature:Auth:Bearer": true, + "Feature:Auth:Custom": true, + "Feature:Auth:Kerberos": true, "AuthorizationExpiredTreatment": true, "Optimization:ImplicitDefaultArguments": true, "Optimization:MinimalResets": true, diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index ae009cf45..3ac760fcd 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -22,6 +22,7 @@ from neo4j.api import ( kerberos_auth, basic_auth, + bearer_auth, custom_auth, ) @@ -33,7 +34,18 @@ def test_should_generate_kerberos_auth_token_correctly(): assert auth.scheme == "kerberos" assert auth.principal == "" assert auth.credentials == "I am a base64 service ticket" - assert not auth.realm + assert not hasattr(auth, "ticket") + assert not hasattr(auth, "realm") + assert not hasattr(auth, "parameters") + + +def test_should_generate_bearer_auth_token_correctly(): + auth = bearer_auth("I am a base64 SSO ticket") + assert auth.scheme == "bearer" + assert auth.credentials == "I am a base64 SSO ticket" + assert not hasattr(auth, "principal") + assert not hasattr(auth, "ticket") + assert not hasattr(auth, "realm") assert not hasattr(auth, "parameters") @@ -42,7 +54,7 @@ def test_should_generate_basic_auth_without_realm_correctly(): assert auth.scheme == "basic" assert auth.principal == "molly" assert auth.credentials == "meoooow" - assert not auth.realm + assert not hasattr(auth, "realm") assert not hasattr(auth, "parameters") @@ -55,6 +67,15 @@ def test_should_generate_base_auth_with_realm_correctly(): assert not hasattr(auth, "parameters") +def test_should_generate_base_auth_with_keyword_realm_correctly(): + auth = basic_auth("molly", "meoooow", realm="cat_cafe") + assert auth.scheme == "basic" + assert auth.principal == "molly" + assert auth.credentials == "meoooow" + assert auth.realm == "cat_cafe" + assert not hasattr(auth, "parameters") + + def test_should_generate_custom_auth_correctly(): auth = custom_auth("molly", "meoooow", "cat_cafe", "cat", age="1", color="white") assert auth.scheme == "cat"