diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 059052cd8f..34ba0a5352 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -35,10 +35,11 @@ Each entry in the `providers` array has the following fields: | Field | Required | Description | |---|---|---| | `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. | -| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. | +| `provider` | Yes | Built-in provider key: `github`, `azure-devops`, or `bitbucket`. | | `auth` | Yes | Auth scheme (see below). | | `token` | No | Token value (inline). Use `token_env` instead when possible. | | `token_env` | No | Environment variable name to read the token from. | +| `username` | For `basic` | Username half of a Basic credential — for Bitbucket API tokens, the Atlassian account email. Must not contain `:`. | For `azure-ad` auth, additional fields are required: @@ -48,7 +49,7 @@ For `azure-ad` auth, additional fields are required: | `client_id` | Yes | Service principal client ID. | | `client_secret_env` | Yes | Environment variable containing the client secret. | -Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes. +Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `basic` schemes. ## Providers and auth schemes @@ -141,6 +142,58 @@ Requires `az login` to have been run beforehand. } ``` +### Bitbucket (`bitbucket`) + +| Scheme | Header | Use for | +|---|---|---| +| `bearer` | `Authorization: Bearer ` | Repository / project / workspace access tokens (Bitbucket Cloud), HTTP access tokens (Bitbucket Data Center) | +| `basic` | `Authorization: Basic base64(:)` | Atlassian API tokens (username = Atlassian account email). Bitbucket Cloud app passwords were removed by Atlassian on July 28, 2026 — use an API token or an access token instead. | + +**Example — Bitbucket Cloud access token (recommended):** + +```json +{ + "hosts": ["api.bitbucket.org", "bitbucket.org"], + "provider": "bitbucket", + "auth": "bearer", + "token_env": "BITBUCKET_ACCESS_TOKEN" +} +``` + +Create the token with the **Repositories: Read** scope on the repository +(or project/workspace) that hosts your catalogs and archives. + +**Example — Atlassian API token (Basic auth):** + +```json +{ + "hosts": ["api.bitbucket.org", "bitbucket.org"], + "provider": "bitbucket", + "auth": "basic", + "username": "you@example.com", + "token_env": "ATLASSIAN_API_TOKEN" +} +``` + +**Example — Bitbucket Data Center HTTP access token:** + +```json +{ + "hosts": ["bitbucket.example.com"], + "provider": "bitbucket", + "auth": "bearer", + "token_env": "BITBUCKET_DC_TOKEN" +} +``` + +> **Note:** Bitbucket Cloud serves file downloads (the repository +> **Downloads** section) via a redirect to a pre-signed Amazon S3 URL. +> Specify strips the `Authorization` header on that redirect because the +> target leaves your declared hosts — this is expected and the download +> still succeeds, since the S3 URL is self-authorizing. Pin a `sha256` in +> your catalog entries so the unauthenticated final hop stays +> integrity-checked. + ## Multiple entries You can configure multiple entries for different hosts or organizations: diff --git a/src/specify_cli/authentication/__init__.py b/src/specify_cli/authentication/__init__.py index b4963af76b..312480b4b5 100644 --- a/src/specify_cli/authentication/__init__.py +++ b/src/specify_cli/authentication/__init__.py @@ -41,9 +41,11 @@ def get_provider(key: str) -> AuthProvider | None: def _register_builtins() -> None: """Register all built-in authentication providers (alphabetical).""" from .azure_devops import AzureDevOpsAuth + from .bitbucket import BitbucketAuth from .github import GitHubAuth _register(AzureDevOpsAuth()) + _register(BitbucketAuth()) _register(GitHubAuth()) diff --git a/src/specify_cli/authentication/bitbucket.py b/src/specify_cli/authentication/bitbucket.py new file mode 100644 index 0000000000..24474e6b5b --- /dev/null +++ b/src/specify_cli/authentication/bitbucket.py @@ -0,0 +1,78 @@ +"""Bitbucket authentication provider.""" + +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING + +from .base import AuthProvider + +if TYPE_CHECKING: + from .config import AuthConfigEntry + + +class BitbucketAuth(AuthProvider): + """Bitbucket authentication provider (Cloud and Data Center). + + Supports two auth schemes: + + * ``bearer`` — repository/project/workspace access tokens (Bitbucket + Cloud) and HTTP access tokens (Bitbucket Data Center) + * ``basic`` — username + secret, Base64-encoded as + ``:``. Used for Atlassian API tokens (username is + the Atlassian account email). + + For the ``basic`` scheme the config entry's ``username`` field is + required: :meth:`resolve_token` returns the combined + ``:`` credential, which :meth:`auth_headers` encodes + verbatim. This keeps the ``AuthProvider`` interface unchanged (a single + resolved token string flows from ``resolve_token`` to ``auth_headers``). + """ + + key = "bitbucket" + supported_auth_schemes = ("bearer", "basic") + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Build the ``Authorization`` header for the given scheme. + + For ``basic``, *token* must already be the full + ``:`` credential produced by :meth:`resolve_token`. + """ + if auth_scheme == "bearer": + return {"Authorization": f"Bearer {token}"} + if auth_scheme == "basic": + # Guard the internal contract: both halves must be present. A bare + # secret, ":", or ":" would otherwise become a + # well-formed header with an empty user or secret and a 401. + # partition() splits on the first colon only, so a secret that + # itself contains ':' is preserved intact. + username, sep, secret = token.partition(":") + if not sep or not username or not secret: + raise ValueError( + "BitbucketAuth 'basic' expects a ':' " + "credential with both parts non-empty, as produced by " + "resolve_token()" + ) + encoded = base64.b64encode(token.encode("utf-8")).decode("ascii") + return {"Authorization": f"Basic {encoded}"} + raise ValueError( + f"BitbucketAuth does not support auth scheme {auth_scheme!r}" + ) + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve the credential, combining ``username`` for ``basic``. + + Returns ``None`` when the secret is missing, or — for ``basic`` — + when ``username`` is absent or contains ``:``. Config validation + already enforces both for ``auth.json`` entries, but a + directly-constructed entry must not produce a malformed + ``:`` credential or a ``user:name:`` one that the + server would parse as user ``user`` (RFC 7617 §2). + """ + secret = super().resolve_token(entry) + if entry.auth != "basic": + return secret + username = (entry.username or "").strip() + if not secret or not username or ":" in username: + return None + return f"{username}:{secret}" diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py index 95b8ff99b4..296c5c4d39 100644 --- a/src/specify_cli/authentication/config.py +++ b/src/specify_cli/authentication/config.py @@ -29,6 +29,10 @@ class AuthConfigEntry: tenant_id: str | None = None client_id: str | None = None client_secret_env: str | None = None + # Username half of a Basic credential (required for auth="basic", + # e.g. Bitbucket Atlassian API tokens). Appended last so existing + # positional constructions keep their parameter positions. + username: str | None = None def _default_config_path() -> Path: @@ -177,13 +181,28 @@ def load_auth_config( f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}" ) + username = entry_raw.get("username") + if username is not None and ( + not isinstance(username, str) or not username.strip() + ): + raise ValueError(f"providers[{i}]: 'username' must be a non-empty string") + # RFC 7617 §2: the user-id of a Basic credential must not contain ':' + # — the server splits on the first colon, so this would silently + # authenticate as the wrong user and fail with a confusing 401. + if isinstance(username, str) and ":" in username: + raise ValueError(f"providers[{i}]: 'username' must not contain ':'") + # Validate token source based on auth scheme - if auth in ("bearer", "basic-pat"): - if not token and not token_env: - raise ValueError( - f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'" - ) - elif auth == "azure-ad": + if auth in ("bearer", "basic-pat", "basic") and not token and not token_env: + raise ValueError( + f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'" + ) + if auth == "basic" and not username: + raise ValueError( + f"providers[{i}]: auth='basic' requires 'username' " + "(e.g. the Atlassian account email for Bitbucket API tokens)" + ) + if auth == "azure-ad": tenant_id = entry_raw.get("tenant_id") client_id = entry_raw.get("client_id") client_secret_env = entry_raw.get("client_secret_env") @@ -210,6 +229,7 @@ def load_auth_config( auth=auth, token=token, token_env=_norm(token_env), + username=_norm(username), tenant_id=_norm(entry_raw.get("tenant_id")), client_id=_norm(entry_raw.get("client_id")), client_secret_env=_norm(entry_raw.get("client_secret_env")), diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 38e12edd5f..c2d508c6e7 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -5,6 +5,7 @@ - Registry mechanics (_register, get_provider, duplicate/empty-key guards) - GitHubAuth — bearer headers - AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers +- BitbucketAuth — bearer and basic (username:token) headers - Host matching (find_entries_for_url) - open_url — config-driven auth with fallthrough and redirect stripping - build_request — single-shot request construction @@ -23,6 +24,7 @@ from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider from specify_cli.authentication.azure_devops import AzureDevOpsAuth from specify_cli.authentication.base import AuthProvider +from specify_cli.authentication.bitbucket import BitbucketAuth from specify_cli.authentication.config import ( AuthConfigEntry, find_entries_for_url, @@ -128,6 +130,108 @@ def test_valid_ado_config(self, tmp_path): assert entries[0].provider == "azure-devops" assert entries[0].auth == "basic-pat" + def test_valid_bitbucket_bearer_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["api.bitbucket.org", "bitbucket.org"], + "provider": "bitbucket", + "auth": "bearer", + "token_env": "BITBUCKET_ACCESS_TOKEN", + }] + })) + entries = load_auth_config(cfg) + assert len(entries) == 1 + assert entries[0].provider == "bitbucket" + assert entries[0].auth == "bearer" + assert entries[0].username is None + + def test_valid_bitbucket_basic_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["api.bitbucket.org"], + "provider": "bitbucket", + "auth": "basic", + "username": " you@example.com ", + "token_env": "ATLASSIAN_API_TOKEN", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].auth == "basic" + # Stored normalized, matching token_env/tenant_id handling. + assert entries[0].username == "you@example.com" + + def test_username_field_is_appended_after_azure_fields(self): + # `username` was added to AuthConfigEntry after the Azure AD fields so + # a positional construction written before it existed still maps its + # sixth argument to tenant_id (not to username). + entry = AuthConfigEntry( + ("dev.azure.com",), "azure-devops", "azure-ad", + None, None, "tid", "cid", "SECRET", + ) + assert entry.tenant_id == "tid" + assert entry.client_id == "cid" + assert entry.client_secret_env == "SECRET" + assert entry.username is None + + def test_basic_without_username_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["api.bitbucket.org"], + "provider": "bitbucket", + "auth": "basic", + "token_env": "ATLASSIAN_API_TOKEN", + }] + })) + with pytest.raises(ValueError, match="requires 'username'"): + load_auth_config(cfg) + + def test_basic_without_token_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["api.bitbucket.org"], + "provider": "bitbucket", + "auth": "basic", + "username": "you@example.com", + }] + })) + with pytest.raises(ValueError, match="requires 'token' or 'token_env'"): + load_auth_config(cfg) + + def test_username_with_colon_raises(self, tmp_path): + # RFC 7617 forbids ':' in the user-id; the server would split on the + # first colon and authenticate as the wrong user. + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["api.bitbucket.org"], + "provider": "bitbucket", + "auth": "basic", + "username": "user:name", + "token_env": "ATLASSIAN_API_TOKEN", + }] + })) + with pytest.raises(ValueError, match="must not contain ':'"): + load_auth_config(cfg) + + @pytest.mark.parametrize("username", ["", " ", 42, False]) + def test_invalid_username_raises(self, tmp_path, username): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["api.bitbucket.org"], + "provider": "bitbucket", + "auth": "basic", + "username": username, + "token_env": "ATLASSIAN_API_TOKEN", + }] + })) + with pytest.raises(ValueError, match="username"): + load_auth_config(cfg) + def test_inline_token(self, tmp_path): cfg = tmp_path / "auth.json" cfg.write_text(json.dumps({ @@ -448,6 +552,12 @@ def test_get_provider_returns_github(self): def test_get_provider_returns_azure_devops(self): assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth) + def test_bitbucket_registered(self): + assert "bitbucket" in AUTH_REGISTRY + + def test_get_provider_returns_bitbucket(self): + assert isinstance(get_provider("bitbucket"), BitbucketAuth) + def test_get_provider_unknown_returns_none(self): assert get_provider("does-not-exist") is None @@ -826,6 +936,123 @@ def test_resolve_token_azure_ad_invalid_utf8_returns_none(self, monkeypatch): assert AzureDevOpsAuth().resolve_token(entry) is None +# --------------------------------------------------------------------------- +# BitbucketAuth +# --------------------------------------------------------------------------- + + +def _bitbucket_basic_entry( + username: str | None = "you@example.com", + token: str | None = None, + token_env: str | None = "ATLASSIAN_API_TOKEN", +) -> AuthConfigEntry: + """Build a Bitbucket basic config entry.""" + return AuthConfigEntry( + hosts=("api.bitbucket.org", "bitbucket.org"), + provider="bitbucket", + auth="basic", + token=token, + token_env=token_env if token is None else None, + username=username, + ) + + +class TestBitbucketAuth: + def test_bearer_headers(self): + headers = BitbucketAuth().auth_headers("bb-token", "bearer") + assert headers == {"Authorization": "Bearer bb-token"} + + def test_basic_headers_encode_credential_verbatim(self): + # For "basic", resolve_token has already combined username:secret; + # auth_headers must encode that credential as-is. + headers = BitbucketAuth().auth_headers("you@example.com:api-tok", "basic") + expected = base64.b64encode(b"you@example.com:api-tok").decode("ascii") + assert headers == {"Authorization": f"Basic {expected}"} + + def test_basic_headers_encode_non_ascii_as_utf8(self): + # Deliberately UTF-8 (unlike AzureDevOpsAuth's ASCII encode) so a + # non-ASCII username or secret does not raise UnicodeEncodeError. + credential = "zoë@example.com:pässwörd" + headers = BitbucketAuth().auth_headers(credential, "basic") + expected = base64.b64encode(credential.encode("utf-8")).decode("ascii") + assert headers == {"Authorization": f"Basic {expected}"} + + def test_basic_headers_reject_bare_secret(self): + # Guards the resolve_token -> auth_headers contract: a raw secret + # would otherwise become a valid-looking header with an empty user. + with pytest.raises(ValueError, match=":"): + BitbucketAuth().auth_headers("just-a-secret", "basic") + + @pytest.mark.parametrize("credential", [":secret", "user:", ":"]) + def test_basic_headers_reject_empty_half(self, credential): + # A colon alone is not enough: ":secret" is exactly the empty-user + # credential the guard exists to block, and "user:" has no secret. + with pytest.raises(ValueError, match="both parts non-empty"): + BitbucketAuth().auth_headers(credential, "basic") + + def test_basic_headers_preserve_colons_inside_secret(self): + # Only the first colon separates the halves; a secret containing ':' + # must be encoded intact. + credential = "you@example.com:se:cr:et" + headers = BitbucketAuth().auth_headers(credential, "basic") + expected = base64.b64encode(credential.encode("utf-8")).decode("ascii") + assert headers == {"Authorization": f"Basic {expected}"} + + def test_unsupported_scheme_raises(self): + with pytest.raises(ValueError, match="does not support auth scheme"): + BitbucketAuth().auth_headers("tok", "basic-pat") + + def test_resolve_token_bearer_from_env(self, monkeypatch): + monkeypatch.setenv("BITBUCKET_ACCESS_TOKEN", "bb-secret") + entry = AuthConfigEntry( + hosts=("api.bitbucket.org",), + provider="bitbucket", + auth="bearer", + token_env="BITBUCKET_ACCESS_TOKEN", + ) + assert BitbucketAuth().resolve_token(entry) == "bb-secret" + + def test_resolve_token_basic_combines_username_and_secret(self, monkeypatch): + monkeypatch.setenv("ATLASSIAN_API_TOKEN", "api-tok") + entry = _bitbucket_basic_entry() + assert BitbucketAuth().resolve_token(entry) == "you@example.com:api-tok" + + def test_resolve_token_basic_inline_token(self): + entry = _bitbucket_basic_entry(token="inline-tok") + assert BitbucketAuth().resolve_token(entry) == "you@example.com:inline-tok" + + def test_resolve_token_basic_missing_username_returns_none(self, monkeypatch): + # Config validation requires username, but a directly-constructed + # entry must not yield a malformed ":" credential. + monkeypatch.setenv("ATLASSIAN_API_TOKEN", "api-tok") + entry = _bitbucket_basic_entry(username=None) + assert BitbucketAuth().resolve_token(entry) is None + + def test_resolve_token_basic_blank_username_returns_none(self, monkeypatch): + monkeypatch.setenv("ATLASSIAN_API_TOKEN", "api-tok") + entry = _bitbucket_basic_entry(username=" ") + assert BitbucketAuth().resolve_token(entry) is None + + def test_resolve_token_basic_colon_username_returns_none(self, monkeypatch): + # load_auth_config rejects this, but a directly-constructed entry + # must not yield "user:name:", which a server parses as + # user "user" (RFC 7617 §2). + monkeypatch.setenv("ATLASSIAN_API_TOKEN", "api-tok") + entry = _bitbucket_basic_entry(username="user:name") + assert BitbucketAuth().resolve_token(entry) is None + + def test_resolve_token_basic_missing_secret_returns_none(self, monkeypatch): + monkeypatch.delenv("ATLASSIAN_API_TOKEN", raising=False) + entry = _bitbucket_basic_entry() + assert BitbucketAuth().resolve_token(entry) is None + + def test_key(self): + assert BitbucketAuth().key == "bitbucket" + + def test_supported_schemes(self): + assert BitbucketAuth().supported_auth_schemes == ("bearer", "basic") + + # --------------------------------------------------------------------------- # open_url / build_request — positive tests # --------------------------------------------------------------------------- @@ -843,6 +1070,16 @@ def test_build_request_attaches_auth_for_matching_host(self, monkeypatch): req = build_request("https://github.com/org/repo") assert req.get_header("Authorization") == "Bearer my-token" + def test_build_request_attaches_bitbucket_basic_auth(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("ATLASSIAN_API_TOKEN", "api-tok") + self._set_config(monkeypatch, [_bitbucket_basic_entry()]) + req = build_request( + "https://api.bitbucket.org/2.0/repositories/ws/repo/downloads/x.tar.gz" + ) + expected = base64.b64encode(b"you@example.com:api-tok").decode("ascii") + assert req.get_header("Authorization") == f"Basic {expected}" + def test_build_request_no_auth_for_non_matching_host(self, monkeypatch): from specify_cli.authentication.http import build_request monkeypatch.setenv("GH_TOKEN", "my-token")