Skip to content

Commit 81005c4

Browse files
simonwclaude
andcommitted
Add support for GitHub Enterprise (#75)
For a GitHub Enterprise server, add `"host": "github.example.com"` to the `datasette-auth-github` configuration. This change adds support for configurable GitHub hosts, allowing the plugin to work with GitHub Enterprise installations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7c19093 commit 81005c4

5 files changed

Lines changed: 88 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Datasette plugin that authenticates users against GitHub.
3636
}
3737
```
3838

39+
For a GitHub Enterprise server, add `"host": "github.example.com"` to `datasette-auth-github`.
40+
3941
Now you can start Datasette like this, passing in the secrets as environment variables:
4042

4143
$ GITHUB_CLIENT_ID=XXX GITHUB_CLIENT_SECRET=YYY datasette \

datasette_auth_github/utils.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ async def load_orgs_and_teams(config, profile, access_token):
99
load_orgs = config["load_orgs"]
1010
gh_orgs = []
1111
for org in force_list(load_orgs):
12-
url = "https://api.github.com/orgs/{}/memberships/{}".format(
13-
org, profile["login"]
12+
url = "https://api.{}/orgs/{}/memberships/{}".format(
13+
config["host"], org, profile["login"]
1414
)
1515
async with httpx.AsyncClient() as client:
1616
response = await client.get(
@@ -26,8 +26,8 @@ async def load_orgs_and_teams(config, profile, access_token):
2626
for team in force_list(load_teams):
2727
org_slug, _, team_slug = team.partition("/")
2828
# Figure out the team_id
29-
lookup_url = "https://api.github.com/orgs/{}/teams/{}".format(
30-
org_slug, team_slug
29+
lookup_url = "https://api.{}/orgs/{}/teams/{}".format(
30+
config["host"], org_slug, team_slug
3131
)
3232
async with httpx.AsyncClient() as client:
3333
response = await client.get(
@@ -39,10 +39,8 @@ async def load_orgs_and_teams(config, profile, access_token):
3939
else:
4040
continue
4141
# Now check if user is an active member of the team:
42-
team_membership_url = (
43-
"https://api.github.com/teams/{}/memberships/{}".format(
44-
team_id, profile["login"]
45-
)
42+
team_membership_url = "https://api.{}/teams/{}/memberships/{}".format(
43+
config["host"], team_id, profile["login"]
4644
)
4745
async with httpx.AsyncClient() as client:
4846
response = await client.get(

datasette_auth_github/views.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ def verify_config(config):
1111
config = config or {}
1212
for key in DEPRECATED_KEYS:
1313
assert key not in config, "{} is no longer a supported option".format(key)
14+
config.setdefault("host", "github.com")
1415

1516

1617
async def github_auth_start(datasette):
@@ -20,10 +21,8 @@ async def github_auth_start(datasette):
2021
scope = "read:org"
2122
else:
2223
scope = "user:email"
23-
github_login_url = (
24-
"https://github.com/login/oauth/authorize?scope={}&client_id={}".format(
25-
scope, config["client_id"]
26-
)
24+
github_login_url = "https://{}/login/oauth/authorize?scope={}&client_id={}".format(
25+
config["host"], scope, config["client_id"]
2726
)
2827
return Response.redirect(github_login_url)
2928

@@ -46,7 +45,7 @@ async def github_auth_callback(datasette, request, scope, receive, send):
4645
# Exchange that code for a token
4746
async with httpx.AsyncClient() as client:
4847
github_response = await client.post(
49-
"https://github.com/login/oauth/access_token",
48+
"https://{}/login/oauth/access_token".format(config["host"]),
5049
data={
5150
"client_id": config["client_id"],
5251
"client_secret": config["client_secret"],
@@ -64,7 +63,7 @@ async def github_auth_callback(datasette, request, scope, receive, send):
6463
return await response_error(datasette, "No valid access token")
6564

6665
# Use access_token to verify user
67-
profile_url = "https://api.github.com/user"
66+
profile_url = "https://api.{}/user".format(config["host"])
6867
try:
6968
async with httpx.AsyncClient() as client:
7069
profile = (
@@ -86,7 +85,7 @@ async def github_auth_callback(datasette, request, scope, receive, send):
8685
extras = await load_orgs_and_teams(config, profile, access_token)
8786
actor.update(extras)
8887

89-
# Set a signed cookie and redirect to homepage (respecting 'base_url' setting)
88+
# Set a signed cookie and redirect to homepage (respecting 'base_url' setting)
9089
response = Response.redirect(datasette.urls.path("/"))
9190
response.set_cookie("ds_actor", datasette.sign({"a": actor}, "actor"))
9291
return response

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "datasette-auth-github"
7-
version = "0.13.1"
7+
version = "0.14.0"
88
description = "Datasette plugin and ASGI middleware that authenticates users against GitHub"
99
readme = "README.md"
1010
authors = [{name = "Simon Willison"}]

tests/test_datasette_auth_github.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,76 @@ async def test_database_access_permissions(
198198
cookies = {"ds_actor": auth_response.cookies["ds_actor"]}
199199
databases = await ds.client.get("/.json", cookies=cookies)
200200
assert set(databases.json()["databases"].keys()) == expected_databases
201+
202+
203+
@pytest.mark.asyncio
204+
async def test_github_enterprise_host(tmpdir, httpx_mock):
205+
"""Test that GitHub Enterprise host configuration works correctly"""
206+
# Mock GitHub Enterprise endpoints
207+
enterprise_host = "github.example.com"
208+
209+
httpx_mock.add_response(
210+
url=f"https://{enterprise_host}/login/oauth/access_token",
211+
method="POST",
212+
content=b"access_token=enterprise_access_token",
213+
)
214+
215+
httpx_mock.add_response(
216+
url=f"https://api.{enterprise_host}/user",
217+
json={
218+
"id": 456,
219+
"name": "Enterprise User",
220+
"login": "enterpriseuser",
221+
"email": "enterprise@example.com",
222+
},
223+
)
224+
225+
httpx_mock.add_response(
226+
url=re.compile(
227+
rf"^https://api\.{re.escape(enterprise_host)}/orgs/enterprise-org/memberships/.*"
228+
),
229+
json={"state": "active", "role": "member"},
230+
)
231+
232+
# Create Datasette instance with GitHub Enterprise configuration
233+
filepath = str(tmpdir / "test.db")
234+
ds = Datasette(
235+
[filepath],
236+
metadata={
237+
"plugins": {
238+
"datasette-auth-github": {
239+
"client_id": "enterprise_client_id",
240+
"client_secret": "enterprise_client_secret",
241+
"host": enterprise_host,
242+
"load_orgs": ["enterprise-org"],
243+
}
244+
}
245+
},
246+
)
247+
248+
def create_tables(conn):
249+
sqlite_utils.Database(conn)["example"].insert({"name": "example"})
250+
251+
await ds.get_database().execute_write_fn(create_tables, block=True)
252+
253+
# Test that the auth start URL uses the enterprise host
254+
response = await ds.client.get("/-/github-auth-start", follow_redirects=False)
255+
expected_url = f"https://{enterprise_host}/login/oauth/authorize?scope=read:org&client_id=enterprise_client_id"
256+
assert expected_url == response.headers["location"]
257+
258+
# Test that the auth callback uses the enterprise host for API calls
259+
response = await ds.client.get(
260+
"/-/github-auth-callback?code=enterprise-code",
261+
follow_redirects=False,
262+
)
263+
264+
actor = ds.unsign(response.cookies["ds_actor"], "actor")["a"]
265+
assert {
266+
"id": "github:456",
267+
"display": "enterpriseuser",
268+
"gh_id": "456",
269+
"gh_name": "Enterprise User",
270+
"gh_login": "enterpriseuser",
271+
"gh_email": "enterprise@example.com",
272+
"gh_orgs": ["enterprise-org"],
273+
}.items() <= actor.items()

0 commit comments

Comments
 (0)