Skip to content

Commit 3f5d0c9

Browse files
authored
Merge pull request #35 from dClimate/add-authentication
Add authenticated IPFS RPC connections to IPFSStore
2 parents 70dc3d3 + dba091f commit 3f5d0c9

File tree

3 files changed

+141
-2
lines changed

3 files changed

+141
-2
lines changed

.github/workflows/run-checks.yaml

+65
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,71 @@ jobs:
2525
run_daemon: true
2626
id: ipfs_setup
2727

28+
- name: Install and configure Nginx
29+
run: |
30+
# Install Nginx
31+
sudo apt-get update
32+
sudo apt-get install -y nginx
33+
34+
# Create Nginx config for reverse proxy with auth
35+
cat <<EOF | sudo tee /etc/nginx/sites-available/ipfs
36+
server {
37+
listen 5002;
38+
server_name localhost;
39+
40+
# Default deny unless authenticated
41+
set \$auth_valid 0;
42+
43+
location /api/v0/ {
44+
# Enforce X-API-Key for API key auth
45+
if (\$http_x_api_key = "test") {
46+
set \$auth_valid 1;
47+
}
48+
49+
# Check Bearer token
50+
if (\$http_authorization = "Bearer test") {
51+
set \$auth_valid 1;
52+
}
53+
54+
# Check Basic Auth (test:test = dGVzdDp0ZXN0)
55+
if (\$http_authorization = "Basic dGVzdDp0ZXN0") {
56+
set \$auth_valid 1;
57+
}
58+
59+
# Deny if no valid auth method
60+
if (\$auth_valid = 0) {
61+
return 401 "Unauthorized: Invalid or missing authentication";
62+
}
63+
64+
# Proxy to IPFS RPC API
65+
proxy_pass http://127.0.0.1:5001;
66+
proxy_set_header Host \$host;
67+
proxy_set_header X-Real-IP \$remote_addr;
68+
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
69+
proxy_set_header X-Forwarded-Proto \$scheme;
70+
}
71+
}
72+
EOF
73+
74+
# Enable the site and remove default
75+
sudo ln -s /etc/nginx/sites-available/ipfs /etc/nginx/sites-enabled/
76+
sudo rm -f /etc/nginx/sites-enabled/default
77+
78+
# Test Nginx config
79+
sudo nginx -t
80+
81+
- name: Start Nginx and restart IPFS daemon
82+
run: |
83+
# Start Nginx
84+
sudo systemctl start nginx
85+
86+
# Restart IPFS daemon to ensure it’s running
87+
ipfs shutdown
88+
ipfs daemon &
89+
90+
# Wait for IPFS and Nginx to be ready
91+
sleep 5
92+
2893
- name: Run pytest with coverage
2994
run: uv run pytest --cov=py_hamt tests/ --cov-report=xml
3095

py_hamt/store.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ class IPFSStore(Store):
6666
Use IPFS as a backing store for a HAMT. The IDs returned from save and used by load are IPFS CIDs.
6767
6868
Save methods use the RPC API but `load` uses the HTTP Gateway, so read-only HAMTs will only access the HTTP Gateway. This allows for connection to remote gateways as well.
69+
70+
You can write to an authenticated IPFS node by providing credentials in the constructor. The following authentication methods are supported:
71+
- Basic Authentication: Provide a tuple of (username, password) to the `basic_auth` parameter.
72+
- Bearer Token: Provide a bearer token to the `bearer_token` parameter.
73+
- API Key: Provide an API key to the `api_key` parameter. You can customize the header name for the API key by setting the `api_key_header` parameter.
6974
"""
7075

7176
def __init__(
@@ -76,6 +81,11 @@ def __init__(
7681
hasher: str = "blake3",
7782
pin_on_add: bool = False,
7883
debug: bool = False,
84+
# Authentication parameters
85+
basic_auth: tuple[str, str] | None = None, # (username, password)
86+
bearer_token: str | None = None,
87+
api_key: str | None = None,
88+
api_key_header: str = "X-API-Key", # Customizable API key header
7989
):
8090
self.timeout_seconds = timeout_seconds
8191
"""
@@ -100,6 +110,16 @@ def __init__(
100110
self.total_received: None | int = 0 if debug else None
101111
"""Total bytes in responses from IPFS for blocks. Used for debugging purposes."""
102112

113+
# Authentication settings
114+
self.basic_auth = basic_auth
115+
"""Tuple of (username, password) for Basic Authentication"""
116+
self.bearer_token = bearer_token
117+
"""Bearer token for token-based authentication"""
118+
self.api_key = api_key
119+
"""API key for API key-based authentication"""
120+
self.api_key_header = api_key_header
121+
"""Header name to use for API key authentication"""
122+
103123
def save(self, data: bytes, cid_codec: str) -> CID:
104124
"""
105125
This saves the data to an ipfs daemon by calling the RPC API, and then returns the CID, with a multicodec set by the input cid_codec. We need to do this since the API always returns either a multicodec of raw or dag-pb if it had to shard the input data.
@@ -116,9 +136,23 @@ def save(self, data: bytes, cid_codec: str) -> CID:
116136
"""
117137
pin_string: str = "true" if self.pin_on_add else "false"
118138

139+
# Apply authentication based on provided credentials
140+
headers = {}
141+
if self.bearer_token:
142+
headers["Authorization"] = f"Bearer {self.bearer_token}"
143+
elif self.api_key:
144+
headers[self.api_key_header] = self.api_key
145+
146+
# Prepare request parameters
147+
url = f"{self.rpc_uri_stem}/api/v0/add?hash={self.hasher}&pin={pin_string}"
148+
149+
# Make the request with appropriate authentication
119150
response = requests.post(
120-
f"{self.rpc_uri_stem}/api/v0/add?hash={self.hasher}&pin={pin_string}",
151+
url,
121152
files={"file": data},
153+
headers=headers,
154+
auth=self.basic_auth,
155+
timeout=self.timeout_seconds,
122156
)
123157
response.raise_for_status()
124158

tests/test_zarr_ipfs.py

+41-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from py_hamt import HAMT, IPFSStore, create_zarr_encryption_transformers
1313

1414

15-
@pytest.fixture
15+
@pytest.fixture(scope="module")
1616
def random_zarr_dataset():
1717
"""Creates a random xarray Dataset and saves it to a temporary zarr store.
1818
@@ -137,3 +137,43 @@ def test_encryption(random_zarr_dataset: tuple[str, xr.Dataset]):
137137
# We should be unable to read precipitation values which are still encrypted
138138
with pytest.raises(Exception):
139139
ds.precip.sum()
140+
141+
142+
# This test assumes the other IPFSStore zarr ipfs tests are working fine, so if other things are breaking check those first
143+
def test_authenticated_gateway(random_zarr_dataset: tuple[str, xr.Dataset]):
144+
zarr_path, test_ds = random_zarr_dataset
145+
146+
def write_and_check(store: IPFSStore) -> bool:
147+
try:
148+
store.rpc_uri_stem = "http://127.0.0.1:5002" # 5002 is the port configured in the run-checks.yaml actions file for nginx to serve the proxy on
149+
hamt = HAMT(store=store)
150+
test_ds.to_zarr(store=hamt, mode="w")
151+
loaded_ds = xr.open_zarr(store=hamt)
152+
xr.testing.assert_identical(test_ds, loaded_ds)
153+
return True
154+
except Exception as _:
155+
return False
156+
157+
# Test with API Key
158+
api_key_store = IPFSStore(api_key="test")
159+
assert write_and_check(api_key_store)
160+
161+
# Test that wrong API Key fails
162+
bad_api_key_store = IPFSStore(api_key="badKey")
163+
assert not write_and_check(bad_api_key_store)
164+
165+
# Test just bearer token
166+
bearer_ipfs_store = IPFSStore(bearer_token="test")
167+
assert write_and_check(bearer_ipfs_store)
168+
169+
# Test with wrong bearer
170+
bad_bearer_store = IPFSStore(bearer_token="wrongBearer")
171+
assert not write_and_check(bad_bearer_store)
172+
173+
# Test with just basic auth
174+
basic_auth_store = IPFSStore(basic_auth=("test", "test"))
175+
assert write_and_check(basic_auth_store)
176+
177+
# Test with wrong basic auth
178+
bad_basic_auth_store = IPFSStore(basic_auth=("wrong", "wrong"))
179+
assert not write_and_check(bad_basic_auth_store)

0 commit comments

Comments
 (0)