imbi-plugin-github 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ """GitHub identity plugins.
2
+
3
+ Three concrete subclasses share one base and differ only by host:
4
+
5
+ * :class:`GitHubPlugin` — github.com.
6
+ * :class:`GitHubEnterpriseCloudPlugin` — GHEC tenant on ``*.ghe.com``.
7
+ * :class:`GitHubEnterpriseServerPlugin` — operator-managed GHES; the
8
+ hostname comes from a manifest option.
9
+
10
+ Phase 1 implements the OAuth App flow only. GitHub App installation
11
+ tokens (org-scoped automation) are deferred — service principals
12
+ continue to use the legacy ``ServiceApplication`` model.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import logging
19
+ import secrets
20
+ import typing
21
+ import urllib.parse
22
+
23
+ import httpx
24
+ from imbi_common.plugins.base import (
25
+ AuthorizationRequest,
26
+ CredentialField,
27
+ IdentityCredentials,
28
+ IdentityPlugin,
29
+ IdentityProfile,
30
+ PluginContext,
31
+ PluginManifest,
32
+ PluginOption,
33
+ )
34
+
35
+ from imbi_plugin_github._hosts import normalize_host, require_ghec_tenant_host
36
+
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+ # Default scope set for the GitHub identity flow. Each scope maps to
40
+ # at least one endpoint the plugin family actually calls:
41
+ # * ``read:user`` — ``GET /user`` (sign-in / profile)
42
+ # * ``user:email`` — ``GET /user/emails`` (verified email fallback)
43
+ # * ``repo`` — ``GET /repos/{owner}/{repo}/...`` (branches,
44
+ # tags, commits, compare, check-runs, action
45
+ # runs) plus ``POST /git/refs`` and
46
+ # ``POST /releases`` for the Promote tab. There
47
+ # is no read-only equivalent in OAuth classic
48
+ # once private repos are in play.
49
+ # * ``workflow`` — ``POST /actions/workflows/{file}/dispatches``
50
+ # (Deploy tab).
51
+ # ``read:org`` was deliberately dropped — no org/team endpoints are
52
+ # called today. Operators that need a narrower bind can override this
53
+ # on the identity assignment via the ``default_scopes`` plugin option.
54
+ DEFAULT_SCOPES = [
55
+ 'read:user',
56
+ 'user:email',
57
+ 'repo',
58
+ 'workflow',
59
+ ]
60
+
61
+
62
+ def _build_userinfo(claims: dict[str, typing.Any]) -> IdentityProfile:
63
+ """Map a GitHub ``/user`` payload onto :class:`IdentityProfile`.
64
+
65
+ GitHub does not assert ``email_verified``; the email returned from
66
+ the API is the user's primary verified email when one is present.
67
+ The login flow enforces verification by additionally fetching
68
+ ``/user/emails`` and only accepting the row marked
69
+ ``primary=true, verified=true``.
70
+ """
71
+ return IdentityProfile(
72
+ subject=str(claims.get('id', '')),
73
+ email=claims.get('email'),
74
+ email_verified=True if claims.get('email') else False,
75
+ name=claims.get('name') or claims.get('login'),
76
+ avatar_url=claims.get('avatar_url'),
77
+ groups=[],
78
+ raw_claims=claims,
79
+ )
80
+
81
+
82
+ class _GitHubBase(IdentityPlugin):
83
+ """Shared base for GitHub identity plugins.
84
+
85
+ Subclasses set :attr:`manifest` and :meth:`_resolve_host`.
86
+ """
87
+
88
+ @classmethod
89
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
90
+ """Return the hostname this plugin instance targets."""
91
+ raise NotImplementedError
92
+
93
+ def _endpoints(self, options: dict[str, typing.Any]) -> dict[str, str]:
94
+ host = self._resolve_host(options)
95
+ # github.com routes login through github.com/login but API
96
+ # requests through api.github.com. GHEC with Data Residency
97
+ # tenants (``*.ghe.com``) are isolated: OAuth authorize/token
98
+ # live on the tenant host and REST traffic on
99
+ # ``api.<tenant>.ghe.com``. GHES appliances host OAuth at
100
+ # ``<host>/login/oauth/...`` and REST at ``<host>/api/v3/...``.
101
+ if host == 'github.com':
102
+ return {
103
+ 'authorize': 'https://github.com/login/oauth/authorize',
104
+ 'token': 'https://github.com/login/oauth/access_token',
105
+ 'user': 'https://api.github.com/user',
106
+ 'emails': 'https://api.github.com/user/emails',
107
+ }
108
+ if host.endswith('.ghe.com'):
109
+ api_host = f'api.{host}'
110
+ return {
111
+ 'authorize': f'https://{host}/login/oauth/authorize',
112
+ 'token': f'https://{host}/login/oauth/access_token',
113
+ 'user': f'https://{api_host}/user',
114
+ 'emails': f'https://{api_host}/user/emails',
115
+ }
116
+ return {
117
+ 'authorize': f'https://{host}/login/oauth/authorize',
118
+ 'token': f'https://{host}/login/oauth/access_token',
119
+ 'user': f'https://{host}/api/v3/user',
120
+ 'emails': f'https://{host}/api/v3/user/emails',
121
+ }
122
+
123
+ @staticmethod
124
+ def _scopes(
125
+ options: dict[str, typing.Any],
126
+ scopes: list[str] | None,
127
+ ) -> list[str]:
128
+ if scopes:
129
+ return scopes
130
+ raw = options.get('default_scopes')
131
+ if isinstance(raw, str) and raw.strip():
132
+ return raw.split()
133
+ if isinstance(raw, list):
134
+ items: list[typing.Any] = raw # pyright: ignore[reportUnknownVariableType]
135
+ return [str(s) for s in items]
136
+ return list(DEFAULT_SCOPES)
137
+
138
+ async def authorization_request(
139
+ self,
140
+ ctx: PluginContext,
141
+ credentials: dict[str, str],
142
+ redirect_uri: str,
143
+ scopes: list[str] | None = None,
144
+ ) -> AuthorizationRequest:
145
+ endpoints = self._endpoints(ctx.assignment_options)
146
+ scope_list = self._scopes(ctx.assignment_options, scopes)
147
+ params: dict[str, str] = {
148
+ 'client_id': credentials['client_id'],
149
+ 'redirect_uri': redirect_uri,
150
+ 'scope': ' '.join(scope_list),
151
+ 'state': secrets.token_urlsafe(16),
152
+ }
153
+ url = endpoints['authorize'] + '?' + urllib.parse.urlencode(params)
154
+ return AuthorizationRequest(
155
+ authorization_url=url,
156
+ state=params['state'],
157
+ )
158
+
159
+ async def exchange_code(
160
+ self,
161
+ ctx: PluginContext,
162
+ credentials: dict[str, str],
163
+ code: str,
164
+ redirect_uri: str,
165
+ code_verifier: str | None = None,
166
+ ) -> tuple[IdentityProfile, IdentityCredentials]:
167
+ endpoints = self._endpoints(ctx.assignment_options)
168
+ data: dict[str, str] = {
169
+ 'client_id': credentials['client_id'],
170
+ 'client_secret': credentials['client_secret'],
171
+ 'code': code,
172
+ 'redirect_uri': redirect_uri,
173
+ }
174
+ async with httpx.AsyncClient(timeout=10.0) as client:
175
+ token_resp = await client.post(
176
+ endpoints['token'],
177
+ data=data,
178
+ headers={'Accept': 'application/json'},
179
+ )
180
+ if token_resp.status_code != 200:
181
+ raise ValueError(
182
+ f'GitHub token exchange failed: {token_resp.status_code} '
183
+ f'{token_resp.text}'
184
+ )
185
+ token = typing.cast(dict[str, typing.Any], token_resp.json())
186
+ if 'access_token' not in token:
187
+ raise ValueError(
188
+ f'GitHub token response missing access_token: {token}'
189
+ )
190
+
191
+ access_token = str(token['access_token'])
192
+ profile = await self._fetch_profile(endpoints, access_token)
193
+
194
+ expires_at: datetime.datetime | None = None
195
+ if 'expires_in' in token:
196
+ expires_at = datetime.datetime.now(
197
+ datetime.UTC
198
+ ) + datetime.timedelta(seconds=int(token['expires_in']))
199
+
200
+ raw_scope = token.get('scope', '')
201
+ scopes: list[str] = (
202
+ [s for s in str(raw_scope).split(',') if s] if raw_scope else []
203
+ )
204
+ return profile, IdentityCredentials(
205
+ access_token=access_token,
206
+ token_type=token.get('token_type', 'Bearer'),
207
+ refresh_token=token.get('refresh_token'),
208
+ expires_at=expires_at,
209
+ scopes=scopes,
210
+ )
211
+
212
+ async def refresh(
213
+ self,
214
+ ctx: PluginContext,
215
+ credentials: dict[str, str],
216
+ refresh_token: str,
217
+ ) -> IdentityCredentials:
218
+ endpoints = self._endpoints(ctx.assignment_options)
219
+ data: dict[str, str] = {
220
+ 'client_id': credentials['client_id'],
221
+ 'client_secret': credentials['client_secret'],
222
+ 'grant_type': 'refresh_token',
223
+ 'refresh_token': refresh_token,
224
+ }
225
+ async with httpx.AsyncClient(timeout=10.0) as client:
226
+ response = await client.post(
227
+ endpoints['token'],
228
+ data=data,
229
+ headers={'Accept': 'application/json'},
230
+ )
231
+ if response.status_code != 200:
232
+ raise ValueError(
233
+ f'GitHub token refresh failed: {response.status_code} '
234
+ f'{response.text}'
235
+ )
236
+ token = typing.cast(dict[str, typing.Any], response.json())
237
+ if 'access_token' not in token:
238
+ raise ValueError(
239
+ f'GitHub refresh response missing access_token: {token}'
240
+ )
241
+ expires_at: datetime.datetime | None = None
242
+ if 'expires_in' in token:
243
+ expires_at = datetime.datetime.now(
244
+ datetime.UTC
245
+ ) + datetime.timedelta(seconds=int(token['expires_in']))
246
+ raw_scope = token.get('scope', '')
247
+ scopes: list[str] = (
248
+ [s for s in str(raw_scope).split(',') if s] if raw_scope else []
249
+ )
250
+ return IdentityCredentials(
251
+ access_token=str(token['access_token']),
252
+ token_type=token.get('token_type', 'Bearer'),
253
+ refresh_token=token.get('refresh_token', refresh_token),
254
+ expires_at=expires_at,
255
+ scopes=scopes,
256
+ )
257
+
258
+ async def _fetch_profile(
259
+ self, endpoints: dict[str, str], access_token: str
260
+ ) -> IdentityProfile:
261
+ async with httpx.AsyncClient(timeout=10.0) as client:
262
+ user_resp = await client.get(
263
+ endpoints['user'],
264
+ headers={
265
+ 'Authorization': f'Bearer {access_token}',
266
+ 'Accept': 'application/vnd.github+json',
267
+ },
268
+ )
269
+ if user_resp.status_code != 200:
270
+ raise ValueError(
271
+ f'GitHub /user failed: {user_resp.status_code} '
272
+ f'{user_resp.text}'
273
+ )
274
+ claims = typing.cast(dict[str, typing.Any], user_resp.json())
275
+ # Fall back to /user/emails when the primary email is
276
+ # private — GitHub returns email=null on /user in that case.
277
+ if not claims.get('email'):
278
+ emails_resp = await client.get(
279
+ endpoints['emails'],
280
+ headers={
281
+ 'Authorization': f'Bearer {access_token}',
282
+ 'Accept': 'application/vnd.github+json',
283
+ },
284
+ )
285
+ if emails_resp.status_code == 200:
286
+ rows = typing.cast(
287
+ list[dict[str, typing.Any]], emails_resp.json()
288
+ )
289
+ primary = next(
290
+ (
291
+ row
292
+ for row in rows
293
+ if row.get('primary') and row.get('verified')
294
+ ),
295
+ None,
296
+ )
297
+ if primary:
298
+ claims['email'] = primary['email']
299
+ return _build_userinfo(claims)
300
+
301
+
302
+ class GitHubPlugin(_GitHubBase):
303
+ manifest = PluginManifest(
304
+ slug='github',
305
+ name='GitHub',
306
+ description='GitHub.com OAuth App identity provider.',
307
+ widget_text=(
308
+ 'Connect to enable functionality such as pull-request visibility, '
309
+ 'project creation, and deployments.'
310
+ ),
311
+ plugin_type='identity',
312
+ auth_type='oauth2',
313
+ login_capable=True,
314
+ default_scopes=DEFAULT_SCOPES,
315
+ options=[
316
+ PluginOption(
317
+ name='default_scopes',
318
+ label='Default scopes (space-separated)',
319
+ type='string',
320
+ ),
321
+ ],
322
+ credentials=[
323
+ CredentialField(
324
+ name='client_id',
325
+ label='Client ID',
326
+ required=True,
327
+ ),
328
+ CredentialField(
329
+ name='client_secret',
330
+ label='Client secret',
331
+ required=True,
332
+ ),
333
+ ],
334
+ )
335
+
336
+ @classmethod
337
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
338
+ return 'github.com'
339
+
340
+
341
+ class GitHubEnterpriseCloudPlugin(_GitHubBase):
342
+ manifest = PluginManifest(
343
+ slug='github-enterprise-cloud',
344
+ name='GitHub Enterprise Cloud',
345
+ description=(
346
+ 'GitHub Enterprise Cloud OAuth App identity provider '
347
+ '(tenant.ghe.com).'
348
+ ),
349
+ widget_text=(
350
+ 'Connect to enable functionality such as pull-request visibility, '
351
+ 'project creation, and deployments.'
352
+ ),
353
+ plugin_type='identity',
354
+ auth_type='oauth2',
355
+ login_capable=True,
356
+ default_scopes=DEFAULT_SCOPES,
357
+ options=[
358
+ PluginOption(
359
+ name='host',
360
+ label='GHEC tenant host',
361
+ description='e.g. tenant.ghe.com',
362
+ type='string',
363
+ required=True,
364
+ ),
365
+ PluginOption(
366
+ name='default_scopes',
367
+ label='Default scopes (space-separated)',
368
+ type='string',
369
+ ),
370
+ ],
371
+ credentials=[
372
+ CredentialField(
373
+ name='client_id',
374
+ label='Client ID',
375
+ required=True,
376
+ ),
377
+ CredentialField(
378
+ name='client_secret',
379
+ label='Client secret',
380
+ required=True,
381
+ ),
382
+ ],
383
+ )
384
+
385
+ @classmethod
386
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
387
+ return require_ghec_tenant_host(
388
+ normalize_host(options.get('host'), 'GHEC plugin'),
389
+ 'GHEC plugin',
390
+ )
391
+
392
+
393
+ class GitHubEnterpriseServerPlugin(_GitHubBase):
394
+ manifest = PluginManifest(
395
+ slug='github-enterprise-server',
396
+ name='GitHub Enterprise Server',
397
+ description=('GitHub Enterprise Server OAuth App identity provider.'),
398
+ widget_text=(
399
+ 'Connect to enable functionality such as pull-request visibility, '
400
+ 'project creation, and deployments.'
401
+ ),
402
+ plugin_type='identity',
403
+ auth_type='oauth2',
404
+ login_capable=True,
405
+ default_scopes=DEFAULT_SCOPES,
406
+ options=[
407
+ PluginOption(
408
+ name='host',
409
+ label='GHES host',
410
+ description='Hostname of the GHES install.',
411
+ type='string',
412
+ required=True,
413
+ ),
414
+ PluginOption(
415
+ name='default_scopes',
416
+ label='Default scopes (space-separated)',
417
+ type='string',
418
+ ),
419
+ ],
420
+ credentials=[
421
+ CredentialField(
422
+ name='client_id',
423
+ label='Client ID',
424
+ required=True,
425
+ ),
426
+ CredentialField(
427
+ name='client_secret',
428
+ label='Client secret',
429
+ required=True,
430
+ ),
431
+ ],
432
+ )
433
+
434
+ @classmethod
435
+ def _resolve_host(cls, options: dict[str, typing.Any]) -> str:
436
+ return normalize_host(options.get('host'), 'GHES plugin')
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: imbi-plugin-github
3
+ Version: 0.1.0
4
+ Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
+ Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
+ License: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Natural Language :: English
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.14
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: imbi-common>=0.8.4
17
+ Requires-Dist: pydantic>=2
18
+ Description-Content-Type: text/markdown
19
+
20
+ # imbi-plugin-github
21
+
22
+ GitHub identity plugin for Imbi. Ships three entry points so the admin UI
23
+ can distinguish github.com, GitHub Enterprise Cloud, and GitHub Enterprise
24
+ Server installations:
25
+
26
+ | Entry point | Slug | Host |
27
+ | -------------------------- | ----------------------------- | ----------------------------------- |
28
+ | `github` | `github` | `github.com` |
29
+ | `github-enterprise-cloud` | `github-enterprise-cloud` | `<tenant>.ghe.com` |
30
+ | `github-enterprise-server` | `github-enterprise-server` | operator-supplied via the `host` option |
31
+
32
+ Phase 1 ships the OAuth App flow only; GitHub App installation tokens are
33
+ deferred. The access token returned by the OAuth grant is passed straight
34
+ to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
35
+
36
+ ## Manifest options
37
+
38
+ | Option | Required | Description |
39
+ | ---------------- | --------- | -------------------------------------------------------------------------- |
40
+ | `host` | GHEC/GHES | Tenant or appliance host (e.g. `tenant.ghe.com`, `github.example.com`). |
41
+ | `default_scopes` | no | Space-separated default OAuth scopes (default: `read:user user:email`). |
42
+
43
+ ## Credentials
44
+
45
+ | Field | Required |
46
+ | ---------------- | -------- |
47
+ | `client_id` | yes |
48
+ | `client_secret` | yes |
49
+
50
+ ## License
51
+
52
+ BSD-3-Clause.
@@ -0,0 +1,9 @@
1
+ imbi_plugin_github/__init__.py,sha256=8ZVhm8XpGqgYRUtpHl0uuZTT3gmMyHNhKd9k6qoatB4,298
2
+ imbi_plugin_github/_hosts.py,sha256=85HWZpXfFsFUUCkUQaH_89um6JZoqX0CBohMxijgYiw,1576
3
+ imbi_plugin_github/deployment.py,sha256=vP2Xy9g6bmimjBweRsuEgIzQKxbHxprUpWxz_OrReis,37007
4
+ imbi_plugin_github/identity.py,sha256=G2sKYRP3E8ranE_2vzU1UKBMojASYOZS6SWvdL7A3Ag,15581
5
+ imbi_plugin_github-0.1.0.dist-info/METADATA,sha256=0k_aEeZz5BJ8nBVibe88tQYaUu0Vat1JagfGmRSIXFs,2203
6
+ imbi_plugin_github-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ imbi_plugin_github-0.1.0.dist-info/entry_points.txt,sha256=68UubEFN70ufwnpNkSnzTUUDSeu2hNNUs6YBN6qOcrI,487
8
+ imbi_plugin_github-0.1.0.dist-info/licenses/LICENSE,sha256=el9B20zOqEcv_mRrHgbdkVyxSC6I8np7am0D6mO2A9Y,1491
9
+ imbi_plugin_github-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ [imbi.plugins]
2
+ github = imbi_plugin_github.identity:GitHubPlugin
3
+ github-deployment = imbi_plugin_github.deployment:GitHubDeploymentPlugin
4
+ github-deployment-ec = imbi_plugin_github.deployment:GitHubEnterpriseCloudDeploymentPlugin
5
+ github-deployment-es = imbi_plugin_github.deployment:GitHubEnterpriseServerDeploymentPlugin
6
+ github-enterprise-cloud = imbi_plugin_github.identity:GitHubEnterpriseCloudPlugin
7
+ github-enterprise-server = imbi_plugin_github.identity:GitHubEnterpriseServerPlugin
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Imbi
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.