|
| 1 | +"""Derived from uts/rest/unit/auth/auth_callback.md in ably/specification. |
| 2 | +
|
| 3 | +Spec points: RSA8c, RSA8d |
| 4 | +""" |
| 5 | + |
| 6 | +import base64 |
| 7 | +import re |
| 8 | +import time |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from ably import api_version |
| 13 | +from ably.types.tokendetails import TokenDetails |
| 14 | +from ably.types.tokenrequest import TokenRequest |
| 15 | +from ably.util.exceptions import AblyException |
| 16 | +from test.uts.helpers.client import rest_client |
| 17 | +from test.uts.helpers.mock_http import MockHttpClient |
| 18 | + |
| 19 | +CHANNEL_BODY = {'channelId': 'test'} |
| 20 | + |
| 21 | +JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload' |
| 22 | + |
| 23 | +REQUEST_TOKEN_PATH = re.compile(r'^/keys/.*/requestToken$') |
| 24 | + |
| 25 | +AUTH_HOST = 'auth.example.com' |
| 26 | + |
| 27 | + |
| 28 | +def now(): |
| 29 | + return int(time.time() * 1000) |
| 30 | + |
| 31 | + |
| 32 | +def bearer(token): |
| 33 | + """The Bearer credential ably-python sends for `token`. |
| 34 | +
|
| 35 | + NOTE: the specification asserts the raw token string. RSA3b makes the |
| 36 | + Base64 encoding optional ("the token string is optionally Base64-encoded |
| 37 | + and used in the `Authorization: Bearer` header"), and ably-python encodes, |
| 38 | + so the specification's literal is one permitted rendering of two. |
| 39 | + """ |
| 40 | + return 'Bearer ' + base64.b64encode(token.encode('utf-8')).decode('ascii') |
| 41 | + |
| 42 | + |
| 43 | +def capture_and_respond(captured_requests, body=None): |
| 44 | + def on_request(request): |
| 45 | + captured_requests.append(request) |
| 46 | + request.respond_with(200, body if body is not None else CHANNEL_BODY) |
| 47 | + |
| 48 | + return on_request |
| 49 | + |
| 50 | + |
| 51 | +# UTS: rest/unit/RSA8d/callback-invoked-for-auth-0 |
| 52 | +async def test_rsa8d_callback_invoked_for_auth(): |
| 53 | + callback_invoked = False |
| 54 | + callback_params = None |
| 55 | + captured_requests = [] |
| 56 | + |
| 57 | + async def auth_callback(params): |
| 58 | + nonlocal callback_invoked, callback_params |
| 59 | + callback_invoked = True |
| 60 | + callback_params = params |
| 61 | + return TokenDetails(token='callback-token', expires=now() + 3600000) |
| 62 | + |
| 63 | + mock_http = MockHttpClient( |
| 64 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 65 | + on_request=capture_and_respond(captured_requests), |
| 66 | + ) |
| 67 | + client = rest_client(mock_http, auth_callback=auth_callback) |
| 68 | + |
| 69 | + await client.request('GET', '/channels/test', version=api_version) |
| 70 | + |
| 71 | + assert callback_invoked is True |
| 72 | + assert callback_params is not None |
| 73 | + |
| 74 | + assert len(captured_requests) == 1 |
| 75 | + assert captured_requests[0].headers['Authorization'] == bearer('callback-token') |
| 76 | + |
| 77 | + |
| 78 | +# UTS: rest/unit/RSA8d/callback-returns-jwt-1 |
| 79 | +async def test_rsa8d_callback_returns_jwt(): |
| 80 | + captured_requests = [] |
| 81 | + |
| 82 | + async def auth_callback(params): |
| 83 | + return JWT |
| 84 | + |
| 85 | + mock_http = MockHttpClient( |
| 86 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 87 | + on_request=capture_and_respond(captured_requests), |
| 88 | + ) |
| 89 | + client = rest_client(mock_http, auth_callback=auth_callback) |
| 90 | + |
| 91 | + await client.request('GET', '/channels/test', version=api_version) |
| 92 | + |
| 93 | + assert captured_requests[0].headers['Authorization'] == bearer(JWT) |
| 94 | + |
| 95 | + |
| 96 | +# UTS: rest/unit/RSA8d/callback-returns-token-request-2 |
| 97 | +async def test_rsa8d_callback_returns_token_request(): |
| 98 | + captured_requests = [] |
| 99 | + |
| 100 | + async def auth_callback(params): |
| 101 | + return TokenRequest( |
| 102 | + key_name='app.key', |
| 103 | + ttl=3600000, |
| 104 | + timestamp=now(), |
| 105 | + nonce='unique-nonce', |
| 106 | + mac='computed-mac', |
| 107 | + ) |
| 108 | + |
| 109 | + def on_request(request): |
| 110 | + captured_requests.append(request) |
| 111 | + if REQUEST_TOKEN_PATH.match(request.path): |
| 112 | + request.respond_with(200, {'token': 'exchanged-token', 'expires': now() + 3600000}) |
| 113 | + else: |
| 114 | + request.respond_with(200, CHANNEL_BODY) |
| 115 | + |
| 116 | + mock_http = MockHttpClient( |
| 117 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 118 | + on_request=on_request, |
| 119 | + ) |
| 120 | + client = rest_client(mock_http, auth_callback=auth_callback) |
| 121 | + |
| 122 | + await client.request('GET', '/channels/test', version=api_version) |
| 123 | + |
| 124 | + assert len(captured_requests) == 2 |
| 125 | + |
| 126 | + first_request = captured_requests[0] |
| 127 | + assert first_request.method == 'POST' |
| 128 | + assert REQUEST_TOKEN_PATH.match(first_request.path) |
| 129 | + |
| 130 | + second_request = captured_requests[1] |
| 131 | + assert second_request.headers['Authorization'] == bearer('exchanged-token') |
| 132 | + |
| 133 | + |
| 134 | +# UTS: rest/unit/RSA8d/callback-receives-token-params-3 |
| 135 | +async def test_rsa8d_callback_receives_token_params(): |
| 136 | + received_params = None |
| 137 | + |
| 138 | + async def auth_callback(params): |
| 139 | + nonlocal received_params |
| 140 | + received_params = params |
| 141 | + return TokenDetails(token='test-token', expires=now() + 3600000) |
| 142 | + |
| 143 | + mock_http = MockHttpClient( |
| 144 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 145 | + on_request=lambda request: request.respond_with(200, CHANNEL_BODY), |
| 146 | + ) |
| 147 | + client = rest_client(mock_http, auth_callback=auth_callback) |
| 148 | + |
| 149 | + await client.auth.authorize(token_params={ |
| 150 | + 'client_id': 'requested-client-id', |
| 151 | + 'ttl': 7200000, |
| 152 | + 'capability': {'channel1': ['publish']}, |
| 153 | + }) |
| 154 | + |
| 155 | + assert received_params['client_id'] == 'requested-client-id' |
| 156 | + assert received_params['ttl'] == 7200000 |
| 157 | + assert received_params['capability'] == {'channel1': ['publish']} |
| 158 | + |
| 159 | + |
| 160 | +def auth_url_mock(captured_requests, token_body=None, token_headers=None): |
| 161 | + """A mock that answers the auth_url with a token and everything else with a channel.""" |
| 162 | + body = token_body if token_body is not None else { |
| 163 | + 'token': 'authurl-token', |
| 164 | + 'expires': now() + 3600000, |
| 165 | + } |
| 166 | + |
| 167 | + def on_request(request): |
| 168 | + captured_requests.append(request) |
| 169 | + if request.url.host == AUTH_HOST: |
| 170 | + request.respond_with(200, body, token_headers) |
| 171 | + else: |
| 172 | + request.respond_with(200, CHANNEL_BODY) |
| 173 | + |
| 174 | + return MockHttpClient( |
| 175 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 176 | + on_request=on_request, |
| 177 | + ) |
| 178 | + |
| 179 | + |
| 180 | +# UTS: rest/unit/RSA8c/authurl-invoked-for-auth-0 |
| 181 | +async def test_rsa8c_authurl_invoked_for_auth(): |
| 182 | + captured_requests = [] |
| 183 | + client = rest_client(auth_url_mock(captured_requests), |
| 184 | + auth_url=f'https://{AUTH_HOST}/token') |
| 185 | + |
| 186 | + await client.request('GET', '/channels/test', version=api_version) |
| 187 | + |
| 188 | + auth_request = captured_requests[0] |
| 189 | + assert auth_request.url.host == AUTH_HOST |
| 190 | + assert auth_request.url.path == '/token' |
| 191 | + assert auth_request.method == 'GET' |
| 192 | + |
| 193 | + api_request = captured_requests[1] |
| 194 | + assert api_request.headers['Authorization'] == bearer('authurl-token') |
| 195 | + |
| 196 | + |
| 197 | +# UTS: rest/unit/RSA8c/authurl-post-method-1 |
| 198 | +async def test_rsa8c_authurl_post_method(): |
| 199 | + captured_requests = [] |
| 200 | + client = rest_client(auth_url_mock(captured_requests), |
| 201 | + auth_url=f'https://{AUTH_HOST}/token', |
| 202 | + auth_method='POST') |
| 203 | + |
| 204 | + await client.request('GET', '/channels/test', version=api_version) |
| 205 | + |
| 206 | + auth_request = captured_requests[0] |
| 207 | + assert auth_request.method == 'POST' |
| 208 | + |
| 209 | + |
| 210 | +# UTS: rest/unit/RSA8c/authurl-custom-headers-2 |
| 211 | +async def test_rsa8c_authurl_custom_headers(): |
| 212 | + captured_requests = [] |
| 213 | + client = rest_client(auth_url_mock(captured_requests), |
| 214 | + auth_url=f'https://{AUTH_HOST}/token', |
| 215 | + auth_headers={ |
| 216 | + 'X-Custom-Header': 'custom-value', |
| 217 | + 'X-API-Key': 'my-api-key', |
| 218 | + }) |
| 219 | + |
| 220 | + await client.request('GET', '/channels/test', version=api_version) |
| 221 | + |
| 222 | + auth_request = captured_requests[0] |
| 223 | + assert auth_request.headers['X-Custom-Header'] == 'custom-value' |
| 224 | + assert auth_request.headers['X-API-Key'] == 'my-api-key' |
| 225 | + |
| 226 | + |
| 227 | +# UTS: rest/unit/RSA8c/authurl-query-params-3 |
| 228 | +async def test_rsa8c_authurl_query_params(): |
| 229 | + captured_requests = [] |
| 230 | + client = rest_client(auth_url_mock(captured_requests), |
| 231 | + auth_url=f'https://{AUTH_HOST}/token', |
| 232 | + auth_params={ |
| 233 | + 'client_id': 'my-client', |
| 234 | + 'scope': 'publish:*', |
| 235 | + }) |
| 236 | + |
| 237 | + await client.request('GET', '/channels/test', version=api_version) |
| 238 | + |
| 239 | + auth_request = captured_requests[0] |
| 240 | + assert auth_request.url.query_params['client_id'] == 'my-client' |
| 241 | + assert auth_request.url.query_params['scope'] == 'publish:*' |
| 242 | + |
| 243 | + |
| 244 | +# UTS: rest/unit/RSA8c/authurl-returns-jwt-4 |
| 245 | +async def test_rsa8c_authurl_returns_jwt(): |
| 246 | + captured_requests = [] |
| 247 | + jwt = 'eyJhbGciOiJIUzI1NiJ9.jwt-body.signature' |
| 248 | + client = rest_client( |
| 249 | + auth_url_mock(captured_requests, token_body=jwt, |
| 250 | + token_headers={'Content-Type': 'text/plain'}), |
| 251 | + auth_url=f'https://{AUTH_HOST}/jwt') |
| 252 | + |
| 253 | + await client.request('GET', '/channels/test', version=api_version) |
| 254 | + |
| 255 | + api_request = captured_requests[1] |
| 256 | + assert api_request.headers['Authorization'] == bearer(jwt) |
| 257 | + |
| 258 | + |
| 259 | +# UTS: rest/unit/RSA8d/callback-error-propagated-4 |
| 260 | +async def test_rsa8d_callback_error_propagated(): |
| 261 | + captured_requests = [] |
| 262 | + |
| 263 | + async def auth_callback(params): |
| 264 | + raise Exception('Authentication server unavailable') |
| 265 | + |
| 266 | + mock_http = MockHttpClient( |
| 267 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 268 | + on_request=capture_and_respond(captured_requests), |
| 269 | + ) |
| 270 | + client = rest_client(mock_http, auth_callback=auth_callback) |
| 271 | + |
| 272 | + with pytest.raises(AblyException) as excinfo: |
| 273 | + await client.request('GET', '/channels/test', version=api_version) |
| 274 | + |
| 275 | + # NOTE: the spec asserts the error message contains the callback's own text. ably-python |
| 276 | + # wraps the callback failure in an AblyException whose `message` names the callback and |
| 277 | + # whose `cause` is the original exception; the rendered error carries both. |
| 278 | + assert excinfo.value.code == 40170 |
| 279 | + assert 'auth_callback raised an exception' in excinfo.value.message |
| 280 | + assert 'Authentication server unavailable' in str(excinfo.value.cause) |
| 281 | + assert 'Authentication server unavailable' in str(excinfo.value) |
| 282 | + |
| 283 | + assert len(captured_requests) == 0 |
| 284 | + |
| 285 | + |
| 286 | +# UTS: rest/unit/RSA8c/authurl-error-propagated-5 |
| 287 | +async def test_rsa8c_authurl_error_propagated(): |
| 288 | + captured_requests = [] |
| 289 | + |
| 290 | + def on_request(request): |
| 291 | + captured_requests.append(request) |
| 292 | + if request.url.host == AUTH_HOST: |
| 293 | + request.respond_with(500, {'error': 'Internal server error'}) |
| 294 | + else: |
| 295 | + request.respond_with(200, CHANNEL_BODY) |
| 296 | + |
| 297 | + mock_http = MockHttpClient( |
| 298 | + on_connection_attempt=lambda conn: conn.respond_with_success(), |
| 299 | + on_request=on_request, |
| 300 | + ) |
| 301 | + client = rest_client(mock_http, auth_url=f'https://{AUTH_HOST}/token') |
| 302 | + |
| 303 | + with pytest.raises(AblyException) as excinfo: |
| 304 | + await client.request('GET', '/channels/test', version=api_version) |
| 305 | + |
| 306 | + assert excinfo.value.status_code == 500 |
| 307 | + |
| 308 | + assert len(captured_requests) == 1 |
| 309 | + assert captured_requests[0].url.host == AUTH_HOST |
0 commit comments