diff --git a/.changeset/sdk-authorization-header.md b/.changeset/sdk-authorization-header.md new file mode 100644 index 0000000000..f17f7f3d35 --- /dev/null +++ b/.changeset/sdk-authorization-header.md @@ -0,0 +1,16 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +The API client accepts an `Authorization` header in place of an API key. Requests can now authenticate with an OAuth access token passed through `apiHeaders` (JS) or `api_headers` (Python), together with the `X-Team-ID` header that selects the team: + +```ts +await Sandbox.create({ + apiHeaders: { Authorization: `Bearer ${accessToken}`, 'X-Team-ID': teamId }, +}) +``` + +```python +Sandbox.create(api_headers={"Authorization": f"Bearer {access_token}", "X-Team-ID": team_id}) +``` diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 9264bfe001..96481181fb 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -103,7 +103,12 @@ class ApiClient { requireApiKey?: boolean } = {} ) { - if ((opts.requireApiKey ?? true) && !config.apiKey) { + // An `Authorization` header (e.g. an OAuth access token passed through + // `apiHeaders`) authenticates the request in place of an API key. + const hasAuthorization = Object.keys(config.headers ?? {}).some( + (name) => name.toLowerCase() === 'authorization' + ) + if ((opts.requireApiKey ?? true) && !config.apiKey && !hasAuthorization) { throw new AuthenticationError( 'API key is required, please visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key. ' + 'You can either set the environment variable `E2B_API_KEY` ' + diff --git a/packages/js-sdk/tests/api/apiKey.test.ts b/packages/js-sdk/tests/api/apiKey.test.ts index 6adfcc782c..93688e411d 100644 --- a/packages/js-sdk/tests/api/apiKey.test.ts +++ b/packages/js-sdk/tests/api/apiKey.test.ts @@ -23,4 +23,28 @@ describe('ApiClient API key requirement', () => { const config = new ConnectionConfig({}) assert.doesNotThrow(() => new ApiClient(config, { requireApiKey: false })) }) + + test.each(['Authorization', 'authorization'])( + 'accepts an %s header in place of an API key', + async (header) => { + vi.stubEnv('E2B_API_KEY', '') + const seen: Headers[] = [] + const config = new ConnectionConfig({ + apiHeaders: { [header]: 'Bearer access-token', 'X-Team-ID': 'team' }, + }) + const client = new ApiClient(config) + await client.api.GET('/teams', { + fetch: async (request: Request) => { + seen.push(request.headers) + return new Response('[]', { + headers: { 'Content-Type': 'application/json' }, + }) + }, + }) + + assert.equal(seen[0].get('Authorization'), 'Bearer access-token') + assert.equal(seen[0].get('X-Team-ID'), 'team') + assert.isNull(seen[0].get('X-API-KEY')) + } + ) }) diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 332d237284..52c5099a6b 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -246,15 +246,31 @@ def __init__( ): self._proxy = config.proxy - if config.api_key is None: + # An `Authorization` header (e.g. an OAuth access token passed through + # `api_headers`) authenticates the request in place of an API key. + authorization = next( + ( + (name, value) + for name, value in (config.headers or {}).items() + if name.lower() == "authorization" + ), + None, + ) + + # Like the JS SDK, an empty E2B_API_KEY counts as unset. + if config.api_key: + token = config.api_key + auth_header_name = "X-API-KEY" + elif authorization is not None: + # The generated client always writes `token` to `auth_header_name`; + # point it at the caller's own header so it isn't sent twice. + auth_header_name, token = authorization + else: raise AuthenticationException( "API key is required, please visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key. " "You can either set the environment variable `E2B_API_KEY` " 'or you can pass it directly to the method like api_key="e2b_..."', ) - - token = config.api_key - auth_header_name = "X-API-KEY" prefix = "" self._logger = config.logger diff --git a/packages/python-sdk/tests/test_api_key.py b/packages/python-sdk/tests/test_api_key.py index d5169bef54..ff190459c9 100644 --- a/packages/python-sdk/tests/test_api_key.py +++ b/packages/python-sdk/tests/test_api_key.py @@ -10,3 +10,32 @@ def test_api_client_requires_api_key(monkeypatch): config = ConnectionConfig() with pytest.raises(AuthenticationException, match=r"API key is required"): ApiClient(config) + + +@pytest.mark.parametrize("header", ["Authorization", "authorization"]) +@pytest.mark.parametrize("env_api_key", [None, ""]) +def test_api_client_accepts_authorization_header(monkeypatch, header, env_api_key): + if env_api_key is None: + monkeypatch.delenv("E2B_API_KEY", raising=False) + else: + monkeypatch.setenv("E2B_API_KEY", env_api_key) + config = ConnectionConfig( + api_headers={header: "Bearer access-token", "X-Team-ID": "team"} + ) + + client = ApiClient(config) + headers = client.get_httpx_client().headers + + assert headers.get_list("Authorization") == ["Bearer access-token"] + assert headers["X-Team-ID"] == "team" + assert "X-API-KEY" not in headers + + +def test_api_client_prefers_api_key(monkeypatch): + monkeypatch.delenv("E2B_API_KEY", raising=False) + config = ConnectionConfig(api_key="e2b_key") + + headers = ApiClient(config).get_httpx_client().headers + + assert headers["X-API-KEY"] == "e2b_key" + assert "Authorization" not in headers