Browse Source

Merge JoinMarket-Org/joinmarket-clientserver#1562: JWT follow-up

c88429d41a JWT authority fixes (roshii)

Pull request description:

  - Fix JWT unit tests (`async` was somehow making tests always successful therefore hiding some issues)
  - Fix `WWW-Authenticate` header construction (https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1480#issuecomment-1731692496)
  - Encode wallet names with base64 in scopes to allow for space delimited names (https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1480#issuecomment-1729669816, https://github.com/joinmarket-webui/jam/issues/663#issuecomment-1735191541)
  - Fix syntax errors in OpenAPI RPC documentation (https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1559)

Top commit has no ACKs.

Tree-SHA512: 6625c4c457c4caf3b4979505334c955bec50fcc0b01707e313dc772571c5c8c8b3ca359a18b5e67f1b0d0eb9b2b7c234ae9716d785234e8de0f3bfb76d53d29a
master
Kristaps Kaupe 2 years ago
parent
commit
28c841359d
No known key found for this signature in database
GPG Key ID: 33E472FE870C7E5D
  1. 9
      docs/api/wallet-rpc.yaml
  2. 12
      src/jmclient/auth.py
  3. 10
      src/jmclient/wallet_rpc.py
  4. 18
      test/jmclient/test_auth.py
  5. 84
      test/jmclient/test_wallet_rpc.py
  6. 13
      test/jmclient/test_websocket.py

9
docs/api/wallet-rpc.yaml

@ -21,7 +21,7 @@ paths:
On initially creating, unlocking or recovering a wallet, store both the refresh and access tokens, the latter is valid for only 30 minutes (must be used for any authenticated call) while the former is for 4 hours (can only be used in the refresh request parameters). Use /token endpoint on a regular basis to get new access and refresh tokens, ideally before access token expiration to avoid authentication errors and in any case, before refresh token expiration. The newly issued tokens must be used in subsequent calls since operation invalidates previously issued tokens. On initially creating, unlocking or recovering a wallet, store both the refresh and access tokens, the latter is valid for only 30 minutes (must be used for any authenticated call) while the former is for 4 hours (can only be used in the refresh request parameters). Use /token endpoint on a regular basis to get new access and refresh tokens, ideally before access token expiration to avoid authentication errors and in any case, before refresh token expiration. The newly issued tokens must be used in subsequent calls since operation invalidates previously issued tokens.
responses: responses:
'200': '200':
$ref: '#/components/responses/RefreshToken-200-OK' $ref: '#/components/responses/Token-200-OK'
'400': '400':
$ref: '#/components/responses/400-BadRequest' $ref: '#/components/responses/400-BadRequest'
requestBody: requestBody:
@ -579,11 +579,6 @@ paths:
required: true required: true
schema: schema:
type: string type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GetSeedResponse'
responses: responses:
'200': '200':
$ref: '#/components/responses/GetSeed-200-OK' $ref: '#/components/responses/GetSeed-200-OK'
@ -684,7 +679,7 @@ components:
token_type: token_type:
type: string type: string
expires_in: expires_in:
type: int type: integer
scope: scope:
type: string type: string
refresh_token: refresh_token:

12
src/jmclient/auth.py

@ -1,5 +1,6 @@
import datetime import datetime
import os import os
from base64 import b64encode
import jwt import jwt
@ -19,6 +20,9 @@ def get_random_key(size: int = 16) -> str:
return bintohex(os.urandom(size)) return bintohex(os.urandom(size))
def b64str(s: str) -> str:
return b64encode(s.encode()).decode()
class JMTokenAuthority: class JMTokenAuthority:
"""Manage authorization tokens.""" """Manage authorization tokens."""
@ -57,13 +61,13 @@ class JMTokenAuthority:
if not self._scope <= token_claims: if not self._scope <= token_claims:
raise InvalidScopeError raise InvalidScopeError
def add_to_scope(self, *args: str): def add_to_scope(self, *args: str, encoded: bool = True):
for arg in args: for arg in args:
self._scope.add(arg) self._scope.add(b64str(arg) if encoded else arg)
def discard_from_scope(self, *args: str): def discard_from_scope(self, *args: str, encoded: bool = True):
for arg in args: for arg in args:
self._scope.discard(arg) self._scope.discard(b64str(arg) if encoded else arg)
@property @property
def scope(self): def scope(self):

10
src/jmclient/wallet_rpc.py

@ -280,10 +280,10 @@ class JMWalletDaemon(Service):
self.taker_finished(False) self.taker_finished(False)
def auth_err(self, request, error, description=None): def auth_err(self, request, error, description=None):
request.setHeader("WWW-Authenticate", "Bearer") value = f'Bearer, error="{error}"'
request.setHeader("WWW-Authenticate", f'error="{error}"')
if description is not None: if description is not None:
request.setHeader("WWW-Authenticate", f'error_description="{description}"') value += f', error_description="{description}"'
request.setHeader("WWW-Authenticate", value)
return return
def err(self, request, message): def err(self, request, message):
@ -305,7 +305,7 @@ class JMWalletDaemon(Service):
@app.handle_errors(InvalidToken) @app.handle_errors(InvalidToken)
def invalid_token(self, request, failure): def invalid_token(self, request, failure):
request.setResponseCode(401) request.setResponseCode(401)
return self.auth_err(request, "invalid_token", str(failure)) return self.auth_err(request, "invalid_token", failure.getErrorMessage())
@app.handle_errors(InsufficientScope) @app.handle_errors(InsufficientScope)
def insufficient_scope(self, request, failure): def insufficient_scope(self, request, failure):
@ -643,7 +643,7 @@ class JMWalletDaemon(Service):
"The requested scope is invalid, unknown, malformed, " "The requested scope is invalid, unknown, malformed, "
"or exceeds the scope granted by the resource owner.", "or exceeds the scope granted by the resource owner.",
) )
except auth.ExpiredSignatureError: except Exception:
return _mkerr( return _mkerr(
"invalid_grant", "invalid_grant",
f"The provided {grant_type} is invalid, revoked, " f"The provided {grant_type} is invalid, revoked, "

18
test/jmclient/test_auth.py

@ -6,7 +6,12 @@ import datetime
import jwt import jwt
import pytest import pytest
from jmclient.auth import ExpiredSignatureError, InvalidScopeError, JMTokenAuthority from jmclient.auth import (
ExpiredSignatureError,
InvalidScopeError,
JMTokenAuthority,
b64str,
)
class TestJMTokenAuthority: class TestJMTokenAuthority:
@ -17,7 +22,7 @@ class TestJMTokenAuthority:
refresh_sig = copy.copy(token_auth.signature_key["refresh"]) refresh_sig = copy.copy(token_auth.signature_key["refresh"])
validity = datetime.timedelta(hours=1) validity = datetime.timedelta(hours=1)
scope = f"walletrpc {wallet_name}" scope = f"walletrpc {b64str(wallet_name)}"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"sig, token_type", [(access_sig, "access"), (refresh_sig, "refresh")] "sig, token_type", [(access_sig, "access"), (refresh_sig, "refresh")]
@ -83,15 +88,16 @@ class TestJMTokenAuthority:
def test_scope_operation(self): def test_scope_operation(self):
assert "walletrpc" in self.token_auth._scope assert "walletrpc" in self.token_auth._scope
assert self.wallet_name in self.token_auth._scope assert b64str(self.wallet_name) in self.token_auth._scope
scope = copy.copy(self.token_auth._scope) scope = copy.copy(self.token_auth._scope)
s = "new_wallet" s = "new_wallet"
self.token_auth.add_to_scope(s) self.token_auth.add_to_scope(s)
assert scope < self.token_auth._scope assert scope < self.token_auth._scope
assert s in self.token_auth._scope assert b64str(s) in self.token_auth._scope
self.token_auth.discard_from_scope(s, "walletrpc") self.token_auth.discard_from_scope(s)
self.token_auth.discard_from_scope("walletrpc", encoded=False)
assert scope > self.token_auth._scope assert scope > self.token_auth._scope
assert s not in self.token_auth._scope assert b64str(s) not in self.token_auth._scope

84
test/jmclient/test_wallet_rpc.py

@ -29,8 +29,7 @@ from jmclient.wallet_rpc import api_version_string, CJ_MAKER_RUNNING, CJ_NOT_RUN
from commontest import make_wallets from commontest import make_wallets
from test_coinjoin import make_wallets_to_list, sync_wallets from test_coinjoin import make_wallets_to_list, sync_wallets
from test_websocket import (ClientTProtocol, test_tx_hex_1, from test_websocket import ClientTProtocol, test_tx_hex_1, test_tx_hex_txid
test_tx_hex_txid, test_token_authority)
pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind") pytestmark = pytest.mark.usefixtures("setup_regtest_bitcoind")
@ -41,10 +40,6 @@ testfilename = "testwrpc"
jlog = get_log() jlog = get_log()
class JMWalletDaemonT(JMWalletDaemon): class JMWalletDaemonT(JMWalletDaemon):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.token = test_token_authority
def check_cookie(self, request, *args, **kwargs): def check_cookie(self, request, *args, **kwargs):
if self.auth_disabled: if self.auth_disabled:
return True return True
@ -220,6 +215,7 @@ class TrialTestWRPC_WS(WalletRPCTestBase, unittest.TestCase):
"ws://127.0.0.1:"+str(self.wss_port), "ws://127.0.0.1:"+str(self.wss_port),
delay=0.1, callbackfn=self.fire_tx_notif) delay=0.1, callbackfn=self.fire_tx_notif)
self.client_factory.protocol = ClientNotifTestProto self.client_factory.protocol = ClientNotifTestProto
self.client_factory.protocol.ACCESS_TOKEN = self.daemon.token.issue()["token"].encode("utf8")
self.client_connector = connectWS(self.client_factory) self.client_connector = connectWS(self.client_factory)
self.attempt_receipt_counter = 0 self.attempt_receipt_counter = 0
return task.deferLater(reactor, 0.0, self.wait_to_receive) return task.deferLater(reactor, 0.0, self.wait_to_receive)
@ -754,22 +750,28 @@ class TrialTestWRPC_DisplayWallet(WalletRPCTestBase, unittest.TestCase):
class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase): class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
@defer.inlineCallbacks
def do_request(self, agent, method, addr, body, handler, token):
headers = Headers({"Authorization": ["Bearer " + token]})
response = yield agent.request(method, addr, headers, bodyProducer=body)
handler(response)
def get_token(self, grant_type: str, status: str = "valid"): def get_token(self, grant_type: str, status: str = "valid"):
now, delta = datetime.datetime.utcnow(), datetime.timedelta(hours=1) now, delta = datetime.datetime.utcnow(), datetime.timedelta(hours=1)
exp = now - delta if status == "expired" else now + delta exp = now - delta if status == "expired" else now + delta
scope = f"walletrpc {self.daemon.wallet_name}" scope = f"walletrpc {self.daemon.wallet_name}"
if status == "invalid_scope": if status == "invalid_scope":
scope = "walletrpc another_wallet" scope = status
alg = test_token_authority.SIGNATURE_ALGORITHM alg = self.daemon.token.SIGNATURE_ALGORITHM
if status == "invalid_alg": if status == "invalid_alg":
alg = ({"HS256", "HS384", "HS512"} - {alg}).pop() alg = ({"HS256", "HS384", "HS512"} - {alg}).pop()
t = jwt.encode( t = jwt.encode(
{"exp": exp, "scope": scope}, {"exp": exp, "scope": scope},
test_token_authority.signature_key[grant_type], self.daemon.token.signature_key[grant_type],
algorithm=test_token_authority.SIGNATURE_ALGORITHM, algorithm=alg,
) )
if status == "invalid_sig": if status == "invalid_sig":
@ -792,22 +794,23 @@ class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
return t return t
def authorized_response_handler(self, response, code): def authorized_response_handler(self, response):
assert code == 200 assert response.code == 200
def forbidden_response_handler(self, response, code): def forbidden_response_handler(self, response):
assert code == 403 assert response.code == 403
assert "insufficient_scope" in response.headers.get("WWW-Authenticate") assert "insufficient_scope" in response.headers.getRawHeaders("WWW-Authenticate").pop()
def unauthorized_response_handler(self, response, code): def unauthorized_response_handler(self, response):
assert code == 401 assert response.code == 401
assert "Bearer" in response.headers.get("WWW-Authenticate") assert "Bearer" in response.headers.getRawHeaders("WWW-Authenticate").pop()
def expired_access_token_response_handler(self, response, code): def expired_access_token_response_handler(self, response):
self.unauthorized_response_handler(response, code) self.unauthorized_response_handler(response)
assert "expired" in response.headers.get("WWW-Authenticate") assert "expired" in response.headers.getRawHeaders("WWW-Authenticate").pop()
async def test_jwt_authentication(self): @defer.inlineCallbacks
def test_jwt_authentication(self):
"""Test JWT authentication and authorization""" """Test JWT authentication and authorization"""
agent = get_nontor_agent() agent = get_nontor_agent()
@ -828,31 +831,37 @@ class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
}[responde_handler] }[responde_handler]
token = self.get_token("access", access_token_status) token = self.get_token("access", access_token_status)
await self.do_request(agent, b"GET", addr, None, handler, token) yield self.do_request(agent, b"GET", addr, None, handler, token)
def successful_refresh_response_handler(self, response, code): @defer.inlineCallbacks
self.authorized_response_handler(response, code) def successful_refresh_response_handler(self, response):
json_body = json.loads(response.decode("utf-8")) self.authorized_response_handler(response)
body = yield readBody(response)
json_body = json.loads(body.decode("utf-8"))
assert {"token", "refresh_token", "expires_in", "token_type", "scope"} <= set( assert {"token", "refresh_token", "expires_in", "token_type", "scope"} <= set(
json_body.keys() json_body.keys()
) )
@defer.inlineCallbacks
def failed_refresh_response_handler( def failed_refresh_response_handler(
self, response, code, *, message=None, error_description=None self, response, *, message=None, error_description=None
): ):
assert code == 400 assert response.code == 400
json_body = json.loads(response.decode("utf-8")) body = yield readBody(response)
json_body = json.loads(body.decode("utf-8"))
if message is not None: if message is not None:
assert json_body.get("message") == message assert json_body.get("message") == message
if error_description is not None: if error_description is not None:
assert error_description in json_body.get("error_description") assert error_description in json_body.get("error_description")
async def do_refresh_request(self, body, handler, token): @defer.inlineCallbacks
def do_refresh_request(self, body, handler, token):
agent = get_nontor_agent() agent = get_nontor_agent()
addr = (self.get_route_root() + "/token").encode() addr = (self.get_route_root() + "/token").encode()
body = BytesProducer(json.dumps(body).encode()) body = BytesProducer(json.dumps(body).encode())
await self.do_request(agent, b"POST", addr, body, handler, token) yield self.do_request(agent, b"POST", addr, body, handler, token)
@defer.inlineCallbacks
def test_refresh_token_request(self): def test_refresh_token_request(self):
"""Test token endpoint with valid refresh token""" """Test token endpoint with valid refresh token"""
for access_token_status, request_status, error in [ for access_token_status, request_status, error in [
@ -864,7 +873,7 @@ class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
if error is None: if error is None:
handler = self.successful_refresh_response_handler handler = self.successful_refresh_response_handler
else: else:
handler = functools.partialmethod( handler = functools.partial(
self.failed_refresh_response_handler, message=error self.failed_refresh_response_handler, message=error
) )
@ -877,11 +886,12 @@ class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
if request_status == "unsupported_grant_type": if request_status == "unsupported_grant_type":
body["grant_type"] = "joinmarket" body["grant_type"] = "joinmarket"
self.do_refresh_request( yield self.do_refresh_request(
body, handler, self.get_token("access", access_token_status) body, handler, self.get_token("access", access_token_status)
) )
async def test_refresh_token(self): @defer.inlineCallbacks
def test_refresh_token(self):
"""Test refresh token endpoint""" """Test refresh token endpoint"""
for refresh_token_status, error in [ for refresh_token_status, error in [
("expired", "expired"), ("expired", "expired"),
@ -889,11 +899,11 @@ class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
("invalid_sig", "invalid_grant"), ("invalid_sig", "invalid_grant"),
]: ]:
if error == "expired": if error == "expired":
handler = functools.partialmethod( handler = functools.partial(
self.failed_refresh_response_handler, error_description=error self.failed_refresh_response_handler, error_description=error
) )
else: else:
handler = functools.partialmethod( handler = functools.partial(
self.failed_refresh_response_handler, message=error self.failed_refresh_response_handler, message=error
) )
@ -902,7 +912,7 @@ class TrialTestWRPC_JWT(WalletRPCTestBase, unittest.TestCase):
"refresh_token": self.get_token("refresh", refresh_token_status), "refresh_token": self.get_token("refresh", refresh_token_status),
} }
self.do_refresh_request(body, handler, self.get_token("access")) yield self.do_refresh_request(body, handler, self.get_token("access"))
""" """

13
test/jmclient/test_websocket.py

@ -21,7 +21,8 @@ test_tx_hex_1 = "02000000000102578770b2732aed421ffe62d54fd695cf281ca336e4f686d2a
test_tx_hex_txid = "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc" test_tx_hex_txid = "ca606efc5ba8f6669ba15e9262e5d38e745345ea96106d5a919688d1ff0da0cc"
# Shared JWT token authority for test: # Shared JWT token authority for test:
test_token_authority = JMTokenAuthority("dummywallet") token_authority = JMTokenAuthority()
class ClientTProtocol(WebSocketClientProtocol): class ClientTProtocol(WebSocketClientProtocol):
""" """
@ -29,11 +30,11 @@ class ClientTProtocol(WebSocketClientProtocol):
message every 2 seconds and print everything it receives. message every 2 seconds and print everything it receives.
""" """
ACCESS_TOKEN = token_authority.issue()["token"].encode("utf8")
def sendAuth(self): def sendAuth(self):
""" Our server will not broadcast """Our server will not broadcast to us unless we authenticate."""
to us unless we authenticate. self.sendMessage(self.ACCESS_TOKEN)
"""
self.sendMessage(test_token_authority.issue()["token"].encode('utf8'))
def onOpen(self): def onOpen(self):
# auth on startup # auth on startup
@ -65,7 +66,7 @@ class WebsocketTestBase(object):
free_ports = get_free_tcp_ports(1) free_ports = get_free_tcp_ports(1)
self.wss_port = free_ports[0] self.wss_port = free_ports[0]
self.wss_url = "ws://127.0.0.1:" + str(self.wss_port) self.wss_url = "ws://127.0.0.1:" + str(self.wss_port)
self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url, test_token_authority) self.wss_factory = JmwalletdWebSocketServerFactory(self.wss_url, token_authority)
self.wss_factory.protocol = JmwalletdWebSocketServerProtocol self.wss_factory.protocol = JmwalletdWebSocketServerProtocol
self.listeningport = listenWS(self.wss_factory, contextFactory=None) self.listeningport = listenWS(self.wss_factory, contextFactory=None)
self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1)) self.test_tx = CTransaction.deserialize(hextobin(test_tx_hex_1))

Loading…
Cancel
Save