Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions docs/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand Down Expand Up @@ -141,6 +142,58 @@ Requires `az login` to have been run beforehand.
}
```

### Bitbucket (`bitbucket`)

| Scheme | Header | Use for |
|---|---|---|
| `bearer` | `Authorization: Bearer <token>` | Repository / project / workspace access tokens (Bitbucket Cloud), HTTP access tokens (Bitbucket Data Center) |
| `basic` | `Authorization: Basic base64(<username>:<token>)` | 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:
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())


Expand Down
78 changes: 78 additions & 0 deletions src/specify_cli/authentication/bitbucket.py
Original file line number Diff line number Diff line change
@@ -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
``<username>:<secret>``. 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
``<username>:<secret>`` 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
``<username>:<secret>`` 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, ":<secret>", or "<username>:" 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 '<username>:<secret>' "
"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
``:<secret>`` credential or a ``user:name:<secret>`` 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}"
32 changes: 26 additions & 6 deletions src/specify_cli/authentication/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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")),
Expand Down
Loading