imbi-plugin-google 2.13.3__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,276 @@
|
|
|
1
|
+
"""Google identity plugin.
|
|
2
|
+
|
|
3
|
+
Sign in with Google (Google Workspace / consumer Google accounts) via the
|
|
4
|
+
OAuth 2.0 authorization-code + PKCE flow. Endpoints are fixed (Google is a
|
|
5
|
+
stable, well-known provider), so unlike the generic OIDC plugin there is no
|
|
6
|
+
discovery round-trip. A Workspace ``hosted_domain`` can be enforced by
|
|
7
|
+
Google itself via the ``hd`` authorization parameter.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import datetime
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import secrets
|
|
17
|
+
import typing
|
|
18
|
+
import urllib.parse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from imbi_common.plugins.base import (
|
|
22
|
+
AuthorizationRequest,
|
|
23
|
+
Capability,
|
|
24
|
+
CredentialField,
|
|
25
|
+
IdentityCapability,
|
|
26
|
+
IdentityCredentials,
|
|
27
|
+
IdentityProfile,
|
|
28
|
+
Plugin,
|
|
29
|
+
PluginContext,
|
|
30
|
+
PluginManifest,
|
|
31
|
+
PluginOption,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
LOGGER = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
AUTHORIZATION_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
37
|
+
TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
|
|
38
|
+
USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo'
|
|
39
|
+
REVOCATION_ENDPOINT = 'https://oauth2.googleapis.com/revoke'
|
|
40
|
+
|
|
41
|
+
DEFAULT_SCOPES = ['openid', 'profile', 'email']
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _b64url(value: bytes) -> str:
|
|
45
|
+
return base64.urlsafe_b64encode(value).rstrip(b'=').decode('ascii')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _generate_pkce() -> tuple[str, str]:
|
|
49
|
+
"""Return ``(code_verifier, code_challenge)`` per RFC 7636 S256."""
|
|
50
|
+
verifier = _b64url(secrets.token_bytes(64))
|
|
51
|
+
challenge = _b64url(hashlib.sha256(verifier.encode('ascii')).digest())
|
|
52
|
+
return verifier, challenge
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GoogleIdentity(IdentityCapability):
|
|
56
|
+
"""Authorization-code + PKCE identity handler for Google."""
|
|
57
|
+
|
|
58
|
+
async def authorization_request(
|
|
59
|
+
self,
|
|
60
|
+
ctx: PluginContext,
|
|
61
|
+
credentials: dict[str, str],
|
|
62
|
+
redirect_uri: str,
|
|
63
|
+
scopes: list[str] | None = None,
|
|
64
|
+
) -> AuthorizationRequest:
|
|
65
|
+
options = ctx.integration_options
|
|
66
|
+
scope_list = scopes or self._default_scopes(options)
|
|
67
|
+
pkce_required = bool(options.get('pkce_required', True))
|
|
68
|
+
|
|
69
|
+
verifier, challenge = _generate_pkce() if pkce_required else ('', '')
|
|
70
|
+
|
|
71
|
+
params: dict[str, str] = {
|
|
72
|
+
'response_type': 'code',
|
|
73
|
+
'client_id': credentials['client_id'],
|
|
74
|
+
'redirect_uri': redirect_uri,
|
|
75
|
+
'scope': ' '.join(scope_list),
|
|
76
|
+
'state': secrets.token_urlsafe(16),
|
|
77
|
+
# Request a refresh token and force the consent screen so one is
|
|
78
|
+
# reliably returned (Google only issues it on first consent).
|
|
79
|
+
'access_type': 'offline',
|
|
80
|
+
'prompt': 'consent',
|
|
81
|
+
}
|
|
82
|
+
if pkce_required:
|
|
83
|
+
params['code_challenge'] = challenge
|
|
84
|
+
params['code_challenge_method'] = 'S256'
|
|
85
|
+
hosted_domain = options.get('hosted_domain')
|
|
86
|
+
if hosted_domain:
|
|
87
|
+
params['hd'] = str(hosted_domain)
|
|
88
|
+
|
|
89
|
+
url = AUTHORIZATION_ENDPOINT + '?' + urllib.parse.urlencode(params)
|
|
90
|
+
return AuthorizationRequest(
|
|
91
|
+
authorization_url=url,
|
|
92
|
+
state=params['state'],
|
|
93
|
+
code_verifier=verifier or None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def exchange_code(
|
|
97
|
+
self,
|
|
98
|
+
ctx: PluginContext,
|
|
99
|
+
credentials: dict[str, str],
|
|
100
|
+
code: str,
|
|
101
|
+
redirect_uri: str,
|
|
102
|
+
code_verifier: str | None = None,
|
|
103
|
+
) -> tuple[IdentityProfile, IdentityCredentials]:
|
|
104
|
+
_ = ctx
|
|
105
|
+
data: dict[str, str] = {
|
|
106
|
+
'grant_type': 'authorization_code',
|
|
107
|
+
'code': code,
|
|
108
|
+
'redirect_uri': redirect_uri,
|
|
109
|
+
'client_id': credentials['client_id'],
|
|
110
|
+
}
|
|
111
|
+
if credentials.get('client_secret'):
|
|
112
|
+
data['client_secret'] = credentials['client_secret']
|
|
113
|
+
if code_verifier:
|
|
114
|
+
data['code_verifier'] = code_verifier
|
|
115
|
+
|
|
116
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
117
|
+
token_resp = await client.post(TOKEN_ENDPOINT, data=data)
|
|
118
|
+
if token_resp.status_code != 200:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f'Token exchange failed: {token_resp.status_code} '
|
|
121
|
+
f'{token_resp.text}'
|
|
122
|
+
)
|
|
123
|
+
token = typing.cast('dict[str, typing.Any]', token_resp.json())
|
|
124
|
+
|
|
125
|
+
profile = await self._userinfo(token['access_token'])
|
|
126
|
+
return profile, self._credentials(token)
|
|
127
|
+
|
|
128
|
+
async def refresh(
|
|
129
|
+
self,
|
|
130
|
+
ctx: PluginContext,
|
|
131
|
+
credentials: dict[str, str],
|
|
132
|
+
refresh_token: str,
|
|
133
|
+
) -> IdentityCredentials:
|
|
134
|
+
_ = ctx
|
|
135
|
+
data: dict[str, str] = {
|
|
136
|
+
'grant_type': 'refresh_token',
|
|
137
|
+
'refresh_token': refresh_token,
|
|
138
|
+
'client_id': credentials['client_id'],
|
|
139
|
+
}
|
|
140
|
+
if credentials.get('client_secret'):
|
|
141
|
+
data['client_secret'] = credentials['client_secret']
|
|
142
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
143
|
+
response = await client.post(TOKEN_ENDPOINT, data=data)
|
|
144
|
+
if response.status_code != 200:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f'Token refresh failed: {response.status_code} {response.text}'
|
|
147
|
+
)
|
|
148
|
+
token = typing.cast('dict[str, typing.Any]', response.json())
|
|
149
|
+
# Google does not return a new refresh token on refresh; keep the
|
|
150
|
+
# existing one so the identity stays renewable.
|
|
151
|
+
return self._credentials(token, fallback_refresh=refresh_token)
|
|
152
|
+
|
|
153
|
+
async def revoke(
|
|
154
|
+
self,
|
|
155
|
+
ctx: PluginContext,
|
|
156
|
+
credentials: dict[str, str],
|
|
157
|
+
token: str,
|
|
158
|
+
) -> None:
|
|
159
|
+
_ = ctx, credentials
|
|
160
|
+
try:
|
|
161
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
162
|
+
await client.post(REVOCATION_ENDPOINT, data={'token': token})
|
|
163
|
+
except httpx.HTTPError:
|
|
164
|
+
LOGGER.warning('Google revocation request failed', exc_info=True)
|
|
165
|
+
|
|
166
|
+
async def _userinfo(self, access_token: str) -> IdentityProfile:
|
|
167
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
168
|
+
response = await client.get(
|
|
169
|
+
USERINFO_ENDPOINT,
|
|
170
|
+
headers={'Authorization': f'Bearer {access_token}'},
|
|
171
|
+
)
|
|
172
|
+
if response.status_code != 200:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f'Userinfo request failed: {response.status_code} '
|
|
175
|
+
f'{response.text}'
|
|
176
|
+
)
|
|
177
|
+
claims = typing.cast('dict[str, typing.Any]', response.json())
|
|
178
|
+
return IdentityProfile(
|
|
179
|
+
subject=str(claims.get('sub', '')),
|
|
180
|
+
email=claims.get('email'),
|
|
181
|
+
email_verified=bool(claims.get('email_verified', False)),
|
|
182
|
+
name=claims.get('name'),
|
|
183
|
+
avatar_url=claims.get('picture'),
|
|
184
|
+
raw_claims=claims,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _credentials(
|
|
189
|
+
token: dict[str, typing.Any],
|
|
190
|
+
fallback_refresh: str | None = None,
|
|
191
|
+
) -> IdentityCredentials:
|
|
192
|
+
expires_at: datetime.datetime | None = None
|
|
193
|
+
if 'expires_in' in token:
|
|
194
|
+
expires_at = datetime.datetime.now(
|
|
195
|
+
datetime.UTC
|
|
196
|
+
) + datetime.timedelta(seconds=int(token['expires_in']))
|
|
197
|
+
scope_str = typing.cast('str', token.get('scope') or '')
|
|
198
|
+
return IdentityCredentials(
|
|
199
|
+
access_token=token['access_token'],
|
|
200
|
+
token_type=token.get('token_type', 'Bearer'),
|
|
201
|
+
refresh_token=token.get('refresh_token', fallback_refresh),
|
|
202
|
+
expires_at=expires_at,
|
|
203
|
+
scopes=scope_str.split(),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _default_scopes(options: dict[str, typing.Any]) -> list[str]:
|
|
208
|
+
raw = options.get('default_scopes')
|
|
209
|
+
if isinstance(raw, str) and raw.strip():
|
|
210
|
+
return raw.split()
|
|
211
|
+
if isinstance(raw, list):
|
|
212
|
+
return [str(s) for s in typing.cast('list[typing.Any]', raw)]
|
|
213
|
+
return list(DEFAULT_SCOPES)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class GooglePlugin(Plugin):
|
|
217
|
+
manifest = PluginManifest(
|
|
218
|
+
slug='google',
|
|
219
|
+
name='Google',
|
|
220
|
+
icon='si-google',
|
|
221
|
+
description='Sign in with a Google or Google Workspace account.',
|
|
222
|
+
auth_type='oidc',
|
|
223
|
+
options=[
|
|
224
|
+
PluginOption(
|
|
225
|
+
name='hosted_domain',
|
|
226
|
+
label='Workspace domain',
|
|
227
|
+
description=(
|
|
228
|
+
'Restrict sign-in to a Google Workspace domain '
|
|
229
|
+
'(sent as the `hd` parameter, e.g. example.com). '
|
|
230
|
+
'Leave blank to allow any Google account.'
|
|
231
|
+
),
|
|
232
|
+
type='string',
|
|
233
|
+
),
|
|
234
|
+
PluginOption(
|
|
235
|
+
name='pkce_required',
|
|
236
|
+
label='Require PKCE',
|
|
237
|
+
type='boolean',
|
|
238
|
+
default=True,
|
|
239
|
+
),
|
|
240
|
+
PluginOption(
|
|
241
|
+
name='default_scopes',
|
|
242
|
+
label='Default scopes (space-separated)',
|
|
243
|
+
type='string',
|
|
244
|
+
),
|
|
245
|
+
],
|
|
246
|
+
credentials=[
|
|
247
|
+
CredentialField(
|
|
248
|
+
name='client_id',
|
|
249
|
+
label='Client ID',
|
|
250
|
+
description='Google OAuth client identifier.',
|
|
251
|
+
required=True,
|
|
252
|
+
secret=False,
|
|
253
|
+
),
|
|
254
|
+
CredentialField(
|
|
255
|
+
name='client_secret',
|
|
256
|
+
label='Client secret',
|
|
257
|
+
description='Google OAuth client secret.',
|
|
258
|
+
required=True,
|
|
259
|
+
),
|
|
260
|
+
],
|
|
261
|
+
capabilities=[
|
|
262
|
+
Capability(
|
|
263
|
+
kind='identity',
|
|
264
|
+
label='Sign in with Google',
|
|
265
|
+
description='Google OAuth 2.0 identity provider.',
|
|
266
|
+
default_enabled=False,
|
|
267
|
+
project_scoped=False,
|
|
268
|
+
hints={
|
|
269
|
+
'login_capable': True,
|
|
270
|
+
'default_scopes': DEFAULT_SCOPES,
|
|
271
|
+
'widget_text': 'Sign in with Google',
|
|
272
|
+
},
|
|
273
|
+
handler=GoogleIdentity,
|
|
274
|
+
),
|
|
275
|
+
],
|
|
276
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imbi-plugin-google
|
|
3
|
+
Version: 2.13.3
|
|
4
|
+
Summary: Google identity plugin for Imbi
|
|
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==2.13.3
|
|
17
|
+
Requires-Dist: pydantic>=2
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# imbi-plugin-google
|
|
21
|
+
|
|
22
|
+
Google identity plugin for Imbi. Implements "Sign in with Google" using the
|
|
23
|
+
OAuth 2.0 authorization-code + PKCE flow against Google's fixed endpoints
|
|
24
|
+
(no discovery round-trip). A Google Workspace domain can be enforced by
|
|
25
|
+
Google itself via the `hd` authorization parameter.
|
|
26
|
+
|
|
27
|
+
## Manifest options
|
|
28
|
+
|
|
29
|
+
| Option | Required | Description |
|
|
30
|
+
| ---------------- | -------- | ------------------------------------------------------------------------------- |
|
|
31
|
+
| `hosted_domain` | no | Restrict sign-in to a Google Workspace domain (sent as `hd`, e.g. `example.com`).|
|
|
32
|
+
| `pkce_required` | no | Use PKCE (default: `true`). |
|
|
33
|
+
| `default_scopes` | no | Space-separated default scopes (default: `openid profile email`). |
|
|
34
|
+
|
|
35
|
+
## Credentials
|
|
36
|
+
|
|
37
|
+
| Field | Required | Secret |
|
|
38
|
+
| --------------- | -------- | ------ |
|
|
39
|
+
| `client_id` | yes | no |
|
|
40
|
+
| `client_secret` | yes | yes |
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
BSD-3-Clause.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
imbi_plugin_google/__init__.py,sha256=II8T2ugMByD11oDaJieq0Tu4JoEM2UStLQRvpnB5gmI,182
|
|
2
|
+
imbi_plugin_google/plugin.py,sha256=gTe7Rti4Ck-GYoJlDSCnid8Jjh0t7ghMXNW-p0PU6wo,9700
|
|
3
|
+
imbi_plugin_google-2.13.3.dist-info/METADATA,sha256=I7d-AQzEw03zRkUtlB8LxorG0yDF0uQyGqi18n2By9s,1719
|
|
4
|
+
imbi_plugin_google-2.13.3.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
imbi_plugin_google-2.13.3.dist-info/licenses/LICENSE,sha256=IR0vx4NgtkggN7vxSSdriTEoiwhNCzQ7816ZTKw_XLg,1535
|
|
6
|
+
imbi_plugin_google-2.13.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, AWeber Communications, Inc.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|