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.
- imbi_plugin_github/__init__.py +13 -0
- imbi_plugin_github/_hosts.py +50 -0
- imbi_plugin_github/deployment.py +999 -0
- imbi_plugin_github/identity.py +436 -0
- imbi_plugin_github-0.1.0.dist-info/METADATA +52 -0
- imbi_plugin_github-0.1.0.dist-info/RECORD +9 -0
- imbi_plugin_github-0.1.0.dist-info/WHEEL +4 -0
- imbi_plugin_github-0.1.0.dist-info/entry_points.txt +7 -0
- imbi_plugin_github-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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,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.
|