ab-auth-client 0.1.11__tar.gz

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,195 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ #pdm.lock
114
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
115
+ # in version control.
116
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
117
+ .pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
122
+ __pypackages__/
123
+
124
+ # Celery stuff
125
+ celerybeat-schedule
126
+ celerybeat.pid
127
+
128
+ # SageMath parsed files
129
+ *.sage.py
130
+
131
+ # Environments
132
+ .env
133
+ .venv
134
+ env/
135
+ venv/
136
+ ENV/
137
+ env.bak/
138
+ venv.bak/
139
+
140
+ # Spyder project settings
141
+ .spyderproject
142
+ .spyproject
143
+
144
+ # Rope project settings
145
+ .ropeproject
146
+
147
+ # mkdocs documentation
148
+ /site
149
+
150
+ # mypy
151
+ .mypy_cache/
152
+ .dmypy.json
153
+ dmypy.json
154
+
155
+ # Pyre type checker
156
+ .pyre/
157
+
158
+ # pytype static type analyzer
159
+ .pytype/
160
+
161
+ # Cython debug symbols
162
+ cython_debug/
163
+
164
+ # PyCharm
165
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
166
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
167
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
168
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
169
+ #.idea/
170
+
171
+ # Abstra
172
+ # Abstra is an AI-powered process automation framework.
173
+ # Ignore directories containing user credentials, local state, and settings.
174
+ # Learn more at https://abstra.io/docs
175
+ .abstra/
176
+
177
+ # Visual Studio Code
178
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
179
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
180
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
181
+ # you could uncomment the following to ignore the entire vscode folder
182
+ # .vscode/
183
+
184
+ # Ruff stuff:
185
+ .ruff_cache/
186
+
187
+ # PyPI configuration file
188
+ .pypirc
189
+
190
+ # Cursor
191
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
192
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
193
+ # refer to https://docs.cursor.com/context/ignore-files
194
+ .cursorignore
195
+ .cursorindexingignore
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Matthew Coulter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: ab-auth-client
3
+ Version: 0.1.11
4
+ Author-email: Matthew Coulter <53892067+mattcoulter7@users.noreply.github.com>
5
+ License-File: LICENSE
6
+ Requires-Python: <4,>=3.12
7
+ Requires-Dist: ab-cache<0.2.0,>=0.1.3
8
+ Requires-Dist: ab-pkce<0.2.0,>=0.1.3
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: pydantic<3,>=2.11.7
11
+ Requires-Dist: requests<3,>=2.32.4
12
+ Requires-Dist: yarl<2,>=1.20.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Open Banking, Opened | Auth
16
+
17
+ Auth Package for Open Banking, Opened API.
@@ -0,0 +1,3 @@
1
+ # Open Banking, Opened | Auth
2
+
3
+ Auth Package for Open Banking, Opened API.
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ build-backend = "hatchling.build"
3
+ requires = ["hatchling"]
4
+
5
+ [dependency-groups]
6
+ dev = [
7
+ "debugpy>=1.8.14,<2",
8
+ "isort>=6.0.1,<7",
9
+ "ab-test-fixtures>=0.1.5,<0.2.0",
10
+ "pre-commit>=4.2.0,<5",
11
+ "pytest-asyncio>=1.0.0,<2",
12
+ "pytest-cov>=6.2.1,<7",
13
+ "pytest>=8.4.1,<9",
14
+ "ruff>=0.12.3,<0.13",
15
+ "tox>=4.27.0,<5"
16
+ ]
17
+
18
+ [project]
19
+ authors = [{email = "53892067+mattcoulter7@users.noreply.github.com", name = "Matthew Coulter"}]
20
+ dependencies = [
21
+ "ab-cache>=0.1.3,<0.2.0",
22
+ "ab-pkce>=0.1.3,<0.2.0",
23
+ "httpx>=0.28.1",
24
+ "pydantic>=2.11.7,<3",
25
+ "requests>=2.32.4,<3",
26
+ "yarl>=1.20.1,<2",
27
+ ]
28
+ description = ""
29
+ name = "ab-auth-client"
30
+ readme = "README.md"
31
+ requires-python = ">=3.12, <4"
32
+ version = "0.1.11"
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["src/ab_core"]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ include = ["src/ab_core"]
39
+
40
+ [tool.hatch.build.targets.wheel.sources]
41
+ "src/ab_core" = "ab_core"
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_default_fixture_loop_scope = "session"
45
+ asyncio_mode = "auto"
46
+ markers = [
47
+ "integration: Assigns the test to the integration test suite.",
48
+ "regression: Assigns the test to the regression test suite"
49
+ ]
50
+
51
+ [tool.ruff]
52
+ line-length = 120
53
+ src = ["src"]
54
+ target-version = "py312"
55
+
56
+ [tool.ruff.lint]
57
+ exclude = [".git", ".venv", "__pycache__", "proto"]
58
+ fixable = ["ALL"]
59
+ ignore = [
60
+ "D104" # missing docstring in public package
61
+ ]
62
+ select = [
63
+ "ARG001", # unused arguments
64
+ "B", # flake8-bugbear
65
+ "C4", # flake8-comprehensions
66
+ "D", # pydocstyle (docstring checks)
67
+ "E", # pycodestyle errors
68
+ "F", # pyflakes
69
+ "I", # isort
70
+ "UP", # pyupgrade
71
+ "W" # pycodestyle warnings
72
+ ]
@@ -0,0 +1,11 @@
1
+ from typing import Annotated, Union
2
+
3
+ from pydantic import Discriminator
4
+
5
+ from .pkce import PKCEOAuth2Client
6
+ from .standard import StandardOAuth2Client
7
+
8
+ OAuth2Client = Annotated[
9
+ StandardOAuth2Client | PKCEOAuth2Client,
10
+ Discriminator("type"),
11
+ ]
@@ -0,0 +1,114 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import (
3
+ Generic,
4
+ TypeVar,
5
+ )
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from ab_core.auth_client.oauth2.schema.authorize import (
11
+ OAuth2AuthorizeResponse,
12
+ OAuth2BuildAuthorizeRequest,
13
+ )
14
+ from ab_core.auth_client.oauth2.schema.exchange import (
15
+ OAuth2ExchangeCodeRequest,
16
+ OAuth2ExchangeFromRedirectUrlRequest,
17
+ )
18
+ from ab_core.auth_client.oauth2.schema.oidc import OIDCConfig
19
+ from ab_core.auth_client.oauth2.schema.refresh import RefreshTokenRequest
20
+ from ab_core.auth_client.oauth2.schema.token import OAuth2Token
21
+ from ab_core.cache.caches.base import CacheAsyncSession, CacheSession
22
+
23
+ BuildReqT = TypeVar("BuildReqT", bound=OAuth2BuildAuthorizeRequest)
24
+ BuildResT = TypeVar("BuildResT", bound=OAuth2AuthorizeResponse)
25
+ ExReqT = TypeVar("ExReqT", bound=OAuth2ExchangeCodeRequest)
26
+ ExUrlReqT = TypeVar("ExUrlReqT", bound=OAuth2ExchangeFromRedirectUrlRequest)
27
+
28
+
29
+ class OAuth2ClientBase(BaseModel, ABC, Generic[BuildReqT, BuildResT, ExReqT, ExUrlReqT]):
30
+ config: OIDCConfig = Field(..., description="OIDC client configuration")
31
+
32
+ # ---------- Authorize URL ----------
33
+ @abstractmethod
34
+ def build_authorize_request(
35
+ self,
36
+ request: BuildReqT,
37
+ *,
38
+ cache_session: CacheSession | None = None, # separate param
39
+ ) -> BuildResT: ...
40
+
41
+ @abstractmethod
42
+ async def build_authorize_request_async(
43
+ self,
44
+ request: BuildReqT,
45
+ *,
46
+ cache_session: CacheAsyncSession | None = None,
47
+ ) -> BuildResT: ...
48
+
49
+ # ---------- Exchanges ----------
50
+ @abstractmethod
51
+ def exchange_code(
52
+ self,
53
+ request: ExReqT,
54
+ *,
55
+ cache_session: CacheSession | None = None,
56
+ ) -> OAuth2Token: ...
57
+
58
+ @abstractmethod
59
+ async def exchange_code_async(
60
+ self,
61
+ request: ExReqT,
62
+ *,
63
+ cache_session: CacheAsyncSession | None = None,
64
+ ) -> OAuth2Token: ...
65
+
66
+ @abstractmethod
67
+ def exchange_from_redirect_url(
68
+ self,
69
+ request: ExUrlReqT,
70
+ *,
71
+ cache_session: CacheSession | None = None,
72
+ ) -> OAuth2Token: ...
73
+
74
+ @abstractmethod
75
+ async def exchange_from_redirect_url_async(
76
+ self,
77
+ request: ExUrlReqT,
78
+ *,
79
+ cache_session: CacheAsyncSession | None = None,
80
+ ) -> OAuth2Token: ...
81
+
82
+ @abstractmethod
83
+ def refresh(
84
+ self,
85
+ request: RefreshTokenRequest,
86
+ *,
87
+ cache_session: CacheSession | None = None,
88
+ ) -> OAuth2Token: ...
89
+
90
+ @abstractmethod
91
+ async def refresh_async(
92
+ self,
93
+ request: RefreshTokenRequest,
94
+ *,
95
+ cache_session: CacheAsyncSession | None = None,
96
+ ) -> OAuth2Token: ...
97
+
98
+ # ---------- Helpers ----------
99
+ def _parse_code_and_state_from_redirect(self, redirect_url: str) -> tuple[str, str | None]:
100
+ p = urlparse(redirect_url)
101
+ qs = parse_qs(p.query)
102
+ code_list = qs.get("code")
103
+ if not code_list or not code_list[0]:
104
+ raise ValueError("No `code` found in redirect URL")
105
+ state = (qs.get("state") or [None])[0]
106
+ return code_list[0], state
107
+
108
+ def _validate_redirect_uri_match(self, redirect_url: str) -> None:
109
+ rp = urlparse(redirect_url)
110
+ cp = urlparse(str(self.config.redirect_uri))
111
+ if (rp.scheme, rp.netloc, rp.path) != (cp.scheme, cp.netloc, cp.path):
112
+ raise ValueError(
113
+ f"Redirect URL `{redirect_url}` does not match configured redirect_uri `{self.config.redirect_uri}`"
114
+ )
@@ -0,0 +1,353 @@
1
+ import base64
2
+ import secrets
3
+ from typing import Literal, override
4
+
5
+ import httpx
6
+ import requests
7
+ from yarl import URL
8
+
9
+ from ab_core.auth_client.oauth2.schema.authorize import (
10
+ PKCEAuthorizeResponse,
11
+ PKCEBuildAuthorizeRequest,
12
+ )
13
+ from ab_core.auth_client.oauth2.schema.client_type import OAuth2ClientType
14
+ from ab_core.auth_client.oauth2.schema.exchange import (
15
+ PKCEExchangeCodeRequest,
16
+ PKCEExchangeFromRedirectUrlRequest,
17
+ )
18
+ from ab_core.auth_client.oauth2.schema.refresh import RefreshTokenRequest
19
+ from ab_core.auth_client.oauth2.schema.token import OAuth2Token
20
+ from ab_core.cache.caches.base import CacheAsyncSession, CacheSession
21
+
22
+ from .base import OAuth2ClientBase
23
+
24
+
25
+ class PKCEOAuth2Client(
26
+ OAuth2ClientBase[
27
+ PKCEBuildAuthorizeRequest,
28
+ PKCEAuthorizeResponse,
29
+ PKCEExchangeCodeRequest,
30
+ PKCEExchangeFromRedirectUrlRequest,
31
+ ]
32
+ ):
33
+ type: Literal[OAuth2ClientType.PKCE] = OAuth2ClientType.PKCE
34
+
35
+ @override
36
+ def build_authorize_request(
37
+ self,
38
+ request: PKCEBuildAuthorizeRequest,
39
+ *,
40
+ cache_session: CacheSession | None = None, # separate param (not in request)
41
+ ) -> PKCEAuthorizeResponse:
42
+ # Base builds URL + state
43
+ state = request.state or base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip("=")
44
+
45
+ q: dict[str, str] = {
46
+ "response_type": request.response_type,
47
+ "client_id": self.config.client_id,
48
+ "redirect_uri": str(self.config.redirect_uri),
49
+ "scope": request.scope,
50
+ "state": state,
51
+ "code_challenge": request.pkce.challenge,
52
+ "code_challenge_method": request.pkce.method.value,
53
+ }
54
+ if request.extra_params:
55
+ q.update({k: str(v) for k, v in request.extra_params.items()})
56
+
57
+ url = str(URL(str(self.config.authorize_url)).with_query(q))
58
+
59
+ res = PKCEAuthorizeResponse(
60
+ url=url,
61
+ state=state,
62
+ code_verifier=request.pkce.verifier,
63
+ code_challenge=request.pkce.challenge,
64
+ code_challenge_method=request.pkce.method.value,
65
+ )
66
+
67
+ # Persist verifier keyed by state if cache available
68
+ if cache_session is not None:
69
+ cache_session.set(
70
+ key=f"pkce:{res.state}",
71
+ value={"verifier": res.code_verifier},
72
+ expiry=request.state_ttl or 600,
73
+ )
74
+
75
+ return res
76
+
77
+ async def build_authorize_request_async(
78
+ self,
79
+ request: PKCEBuildAuthorizeRequest,
80
+ *,
81
+ cache_session: CacheAsyncSession | None = None,
82
+ ) -> PKCEAuthorizeResponse:
83
+ state = request.state or base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip("=")
84
+
85
+ q: dict[str, str] = {
86
+ "response_type": request.response_type,
87
+ "client_id": self.config.client_id,
88
+ "redirect_uri": str(self.config.redirect_uri),
89
+ "scope": request.scope,
90
+ "state": state,
91
+ "code_challenge": request.pkce.challenge,
92
+ "code_challenge_method": request.pkce.method.value,
93
+ }
94
+ if request.extra_params:
95
+ q.update({k: str(v) for k, v in request.extra_params.items()})
96
+
97
+ url = str(URL(str(self.config.authorize_url)).with_query(q))
98
+
99
+ res = PKCEAuthorizeResponse(
100
+ url=url,
101
+ state=state,
102
+ code_verifier=request.pkce.verifier,
103
+ code_challenge=request.pkce.challenge,
104
+ code_challenge_method=request.pkce.method.value,
105
+ )
106
+
107
+ if cache_session is not None:
108
+ await cache_session.set( # assume async cache has awaitable set
109
+ key=f"pkce:{res.state}",
110
+ value={"verifier": res.code_verifier},
111
+ expiry=request.state_ttl or 600,
112
+ )
113
+
114
+ return res
115
+
116
+ # ---- exchanges ----
117
+ @override
118
+ def exchange_code(
119
+ self,
120
+ request: PKCEExchangeCodeRequest,
121
+ *,
122
+ cache_session: CacheSession | None = None,
123
+ ) -> OAuth2Token:
124
+ # in pkce code verifier is needed during exchange
125
+ code_verifier = request.code_verifier
126
+ if code_verifier is None:
127
+ if not request.state:
128
+ raise ValueError("code_verifier missing; provide it or supply state for cache lookup")
129
+ code_verifier = self._lookup_verifier(
130
+ state=request.state,
131
+ delete_after=request.delete_after,
132
+ cache_session=cache_session,
133
+ )
134
+
135
+ payload = {
136
+ "grant_type": "authorization_code",
137
+ "client_id": self.config.client_id,
138
+ "redirect_uri": str(self.config.redirect_uri),
139
+ "code": request.code,
140
+ "code_verifier": code_verifier,
141
+ }
142
+ resp = requests.post(
143
+ self.config.token_url,
144
+ data=payload,
145
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
146
+ )
147
+ resp.raise_for_status()
148
+ return OAuth2Token.model_validate(resp.json())
149
+
150
+ async def exchange_code_async(
151
+ self,
152
+ request: PKCEExchangeCodeRequest,
153
+ *,
154
+ cache_session: CacheAsyncSession | None = None,
155
+ ) -> OAuth2Token:
156
+ code_verifier = request.code_verifier
157
+ if code_verifier is None:
158
+ if not request.state:
159
+ raise ValueError("code_verifier missing; provide it or supply state for cache lookup")
160
+ code_verifier = await self._lookup_verifier_async(
161
+ state=request.state,
162
+ delete_after=request.delete_after,
163
+ cache_session=cache_session,
164
+ )
165
+
166
+ payload = {
167
+ "grant_type": "authorization_code",
168
+ "client_id": self.config.client_id,
169
+ "redirect_uri": str(self.config.redirect_uri),
170
+ "code": request.code,
171
+ "code_verifier": code_verifier,
172
+ }
173
+ async with httpx.AsyncClient() as client:
174
+ resp = await client.post(
175
+ str(self.config.token_url),
176
+ data=payload,
177
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
178
+ timeout=10,
179
+ )
180
+ resp.raise_for_status()
181
+ return OAuth2Token.model_validate(resp.json())
182
+
183
+ @override
184
+ def exchange_from_redirect_url(
185
+ self,
186
+ request: PKCEExchangeFromRedirectUrlRequest,
187
+ *,
188
+ cache_session: CacheSession | None = None,
189
+ ) -> OAuth2Token:
190
+ redirect_url = str(request.redirect_url)
191
+ if request.enforce_redirect_uri_match:
192
+ self._validate_redirect_uri_match(redirect_url)
193
+
194
+ code, state = self._parse_code_and_state_from_redirect(redirect_url)
195
+ if request.expected_state is not None and state != request.expected_state:
196
+ raise ValueError("state mismatch")
197
+ if not state and request.code_verifier is None:
198
+ raise ValueError("no state in redirect URL and no code_verifier supplied")
199
+
200
+ code_verifier = request.code_verifier
201
+ if code_verifier is None:
202
+ code_verifier = self._lookup_verifier(
203
+ state=state,
204
+ delete_after=request.delete_after,
205
+ cache_session=cache_session,
206
+ )
207
+
208
+ payload = {
209
+ "grant_type": "authorization_code",
210
+ "client_id": self.config.client_id,
211
+ "redirect_uri": str(self.config.redirect_uri),
212
+ "code": code,
213
+ "code_verifier": code_verifier,
214
+ }
215
+ resp = requests.post(
216
+ self.config.token_url,
217
+ data=payload,
218
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
219
+ )
220
+ resp.raise_for_status()
221
+ return OAuth2Token.model_validate(resp.json())
222
+
223
+ async def exchange_from_redirect_url_async(
224
+ self,
225
+ request: PKCEExchangeFromRedirectUrlRequest,
226
+ *,
227
+ cache_session: CacheAsyncSession | None = None,
228
+ ) -> OAuth2Token:
229
+ redirect_url = str(request.redirect_url)
230
+ if request.enforce_redirect_uri_match:
231
+ self._validate_redirect_uri_match(redirect_url)
232
+
233
+ code, state = self._parse_code_and_state_from_redirect(redirect_url)
234
+ if request.expected_state is not None and state != request.expected_state:
235
+ raise ValueError("state mismatch")
236
+ if not state and request.code_verifier is None:
237
+ raise ValueError("no state in redirect URL and no code_verifier supplied")
238
+
239
+ code_verifier = request.code_verifier
240
+ if code_verifier is None:
241
+ code_verifier = await self._lookup_verifier_async(
242
+ state=state, # type: ignore[arg-type]
243
+ delete_after=request.delete_after,
244
+ cache_session=cache_session,
245
+ )
246
+
247
+ payload = {
248
+ "grant_type": "authorization_code",
249
+ "client_id": self.config.client_id,
250
+ "redirect_uri": str(self.config.redirect_uri),
251
+ "code": code,
252
+ "code_verifier": code_verifier,
253
+ }
254
+ async with httpx.AsyncClient() as client:
255
+ resp = await client.post(
256
+ str(self.config.token_url),
257
+ data=payload,
258
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
259
+ timeout=10,
260
+ )
261
+ resp.raise_for_status()
262
+ return OAuth2Token.model_validate(resp.json())
263
+
264
+ @override
265
+ def refresh(
266
+ self,
267
+ request: RefreshTokenRequest,
268
+ *,
269
+ cache_session: CacheSession | None = None, # kept for symmetry
270
+ ) -> OAuth2Token:
271
+ payload = {
272
+ "grant_type": "refresh_token",
273
+ "client_id": self.config.client_id,
274
+ "refresh_token": request.refresh_token,
275
+ }
276
+ if request.scope:
277
+ payload["scope"] = request.scope
278
+
279
+ resp = requests.post(
280
+ self.config.token_url,
281
+ data=payload,
282
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
283
+ )
284
+ resp.raise_for_status()
285
+ data = resp.json()
286
+
287
+ # Some IdPs (e.g. Cognito) rotate refresh tokens; keep the new one if present.
288
+ if "refresh_token" not in data:
289
+ data["refresh_token"] = request.refresh_token
290
+
291
+ return OAuth2Token.model_validate(data)
292
+
293
+ async def refresh_async(
294
+ self,
295
+ request: RefreshTokenRequest,
296
+ *,
297
+ cache_session: CacheAsyncSession | None = None,
298
+ ) -> OAuth2Token:
299
+ payload = {
300
+ "grant_type": "refresh_token",
301
+ "client_id": self.config.client_id,
302
+ "refresh_token": request.refresh_token,
303
+ }
304
+ if request.scope:
305
+ payload["scope"] = request.scope
306
+
307
+ async with httpx.AsyncClient() as client:
308
+ resp = await client.post(
309
+ str(self.config.token_url),
310
+ data=payload,
311
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
312
+ timeout=10,
313
+ )
314
+ resp.raise_for_status()
315
+ data = resp.json()
316
+
317
+ if "refresh_token" not in data:
318
+ data["refresh_token"] = request.refresh_token
319
+
320
+ return OAuth2Token.model_validate(data)
321
+
322
+ # ---- internals ----
323
+ def _lookup_verifier(
324
+ self,
325
+ *,
326
+ state: str,
327
+ delete_after: bool,
328
+ cache_session: CacheSession | None = None,
329
+ ) -> str:
330
+ if cache_session is None:
331
+ raise ValueError("code_verifier not provided and no cache_session configured on client")
332
+ rec = cache_session.get(f"pkce:{state}")
333
+ if rec is None or "verifier" not in rec:
334
+ raise ValueError("code_verifier not found in cache for given state")
335
+ if delete_after:
336
+ cache_session.delete(f"pkce:{state}")
337
+ return rec["verifier"]
338
+
339
+ async def _lookup_verifier_async(
340
+ self,
341
+ *,
342
+ state: str,
343
+ delete_after: bool,
344
+ cache_session: CacheAsyncSession | None = None,
345
+ ) -> str:
346
+ if cache_session is None:
347
+ raise ValueError("code_verifier not provided and no cache_session configured on client")
348
+ rec = await cache_session.get(f"pkce:{state}")
349
+ if rec is None or "verifier" not in rec:
350
+ raise ValueError("code_verifier not found in cache for given state")
351
+ if delete_after:
352
+ await cache_session.delete(f"pkce:{state}")
353
+ return rec["verifier"]
@@ -0,0 +1,219 @@
1
+ import base64
2
+ import secrets
3
+ from typing import Literal, override
4
+
5
+ import httpx
6
+ import requests
7
+ from yarl import URL
8
+
9
+ from ab_core.auth_client.oauth2.schema.authorize import (
10
+ OAuth2AuthorizeResponse,
11
+ OAuth2BuildAuthorizeRequest,
12
+ )
13
+ from ab_core.auth_client.oauth2.schema.client_type import OAuth2ClientType
14
+ from ab_core.auth_client.oauth2.schema.exchange import (
15
+ OAuth2ExchangeCodeRequest,
16
+ OAuth2ExchangeFromRedirectUrlRequest,
17
+ )
18
+ from ab_core.auth_client.oauth2.schema.refresh import RefreshTokenRequest
19
+ from ab_core.auth_client.oauth2.schema.token import OAuth2Token
20
+ from ab_core.cache.caches.base import CacheAsyncSession, CacheSession
21
+
22
+ from .base import OAuth2ClientBase
23
+
24
+
25
+ class StandardOAuth2Client(
26
+ OAuth2ClientBase[
27
+ OAuth2BuildAuthorizeRequest,
28
+ OAuth2AuthorizeResponse,
29
+ OAuth2ExchangeCodeRequest,
30
+ OAuth2ExchangeFromRedirectUrlRequest,
31
+ ]
32
+ ):
33
+ type: Literal[OAuth2ClientType.STANDARD] = OAuth2ClientType.STANDARD
34
+
35
+ # ---------- Authorize URL ----------
36
+ @override
37
+ def build_authorize_request(
38
+ self,
39
+ request: OAuth2BuildAuthorizeRequest,
40
+ *,
41
+ cache_session: CacheSession | None = None,
42
+ ) -> OAuth2AuthorizeResponse:
43
+ state = request.state or base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip("=")
44
+
45
+ q: dict[str, str] = {
46
+ "response_type": request.response_type,
47
+ "client_id": self.config.client_id,
48
+ "redirect_uri": str(self.config.redirect_uri),
49
+ "scope": request.scope,
50
+ "state": state,
51
+ }
52
+ if request.extra_params:
53
+ q.update({k: str(v) for k, v in request.extra_params.items()})
54
+
55
+ url = str(URL(str(self.config.authorize_url)).with_query(q))
56
+ # Base returns the base response; subclasses can upcast to their own response type
57
+ return OAuth2AuthorizeResponse(url=url, state=state)
58
+
59
+ async def build_authorize_request_async(
60
+ self,
61
+ request: OAuth2BuildAuthorizeRequest,
62
+ *,
63
+ cache_session: CacheAsyncSession | None = None,
64
+ ) -> OAuth2AuthorizeResponse:
65
+ state = request.state or base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip("=")
66
+
67
+ q: dict[str, str] = {
68
+ "response_type": request.response_type,
69
+ "client_id": self.config.client_id,
70
+ "redirect_uri": str(self.config.redirect_uri),
71
+ "scope": request.scope,
72
+ "state": state,
73
+ }
74
+ if request.extra_params:
75
+ q.update({k: str(v) for k, v in request.extra_params.items()})
76
+
77
+ url = str(URL(str(self.config.authorize_url)).with_query(q))
78
+ # Base returns the base response; subclasses can upcast to their own response type
79
+ return OAuth2AuthorizeResponse(url=url, state=state)
80
+
81
+ @override
82
+ def exchange_code(
83
+ self,
84
+ request: OAuth2ExchangeCodeRequest,
85
+ *,
86
+ cache_session: CacheSession | None = None,
87
+ ) -> OAuth2Token:
88
+ if not self.config.client_secret:
89
+ raise ValueError("client_secret required for standard flow")
90
+ payload = {
91
+ "grant_type": "authorization_code",
92
+ "client_id": self.config.client_id,
93
+ "client_secret": self.config.client_secret,
94
+ "redirect_uri": str(self.config.redirect_uri),
95
+ "code": request.code,
96
+ }
97
+ resp = requests.post(
98
+ self.config.token_url,
99
+ data=payload,
100
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
101
+ )
102
+ resp.raise_for_status()
103
+ return OAuth2Token.model_validate(resp.json())
104
+
105
+ async def exchange_code_async(
106
+ self,
107
+ request: OAuth2ExchangeCodeRequest,
108
+ *,
109
+ cache_session: CacheAsyncSession | None = None,
110
+ ) -> OAuth2Token:
111
+ if not self.config.client_secret:
112
+ raise ValueError("client_secret required for standard flow")
113
+
114
+ payload = {
115
+ "grant_type": "authorization_code",
116
+ "client_id": self.config.client_id,
117
+ "client_secret": self.config.client_secret,
118
+ "redirect_uri": str(self.config.redirect_uri),
119
+ "code": request.code,
120
+ }
121
+ async with httpx.AsyncClient() as client:
122
+ resp = await client.post(
123
+ str(self.config.token_url),
124
+ data=payload,
125
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
126
+ timeout=10,
127
+ )
128
+ resp.raise_for_status()
129
+ return OAuth2Token.model_validate(resp.json())
130
+
131
+ @override
132
+ def exchange_from_redirect_url(
133
+ self,
134
+ request: OAuth2ExchangeFromRedirectUrlRequest,
135
+ *,
136
+ cache_session: CacheSession | None = None,
137
+ ) -> OAuth2Token:
138
+ if request.enforce_redirect_uri_match:
139
+ self._validate_redirect_uri_match(str(request.redirect_url))
140
+ code, state = self._parse_code_and_state_from_redirect(str(request.redirect_url))
141
+ if request.expected_state is not None and state != request.expected_state:
142
+ raise ValueError("state mismatch")
143
+ return self.exchange_code(OAuth2ExchangeCodeRequest(code=code))
144
+
145
+ async def exchange_from_redirect_url_async(
146
+ self,
147
+ request: OAuth2ExchangeFromRedirectUrlRequest,
148
+ *,
149
+ cache_session: CacheAsyncSession | None = None,
150
+ ) -> OAuth2Token:
151
+ if request.enforce_redirect_uri_match:
152
+ self._validate_redirect_uri_match(str(request.redirect_url))
153
+ code, state = self._parse_code_and_state_from_redirect(str(request.redirect_url))
154
+ if request.expected_state is not None and state != request.expected_state:
155
+ raise ValueError("state mismatch")
156
+ return await self.exchange_code_async(OAuth2ExchangeCodeRequest(code=code))
157
+
158
+ @override
159
+ def refresh(
160
+ self,
161
+ request: RefreshTokenRequest,
162
+ *,
163
+ cache_session: CacheSession | None = None, # kept for symmetry
164
+ ) -> OAuth2Token:
165
+ if not self.config.client_secret:
166
+ raise ValueError("client_secret required for standard client refresh")
167
+
168
+ payload: dict[str, str] = {
169
+ "grant_type": "refresh_token",
170
+ "client_id": self.config.client_id,
171
+ "client_secret": self.config.client_secret,
172
+ "refresh_token": request.refresh_token,
173
+ }
174
+ if request.scope:
175
+ payload["scope"] = request.scope # optional; most IdPs ignore unless narrowing
176
+
177
+ resp = requests.post(
178
+ self.config.token_url,
179
+ data=payload,
180
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
181
+ )
182
+ resp.raise_for_status()
183
+ data = resp.json()
184
+
185
+ # Keep original refresh token if server doesn’t rotate it.
186
+ data.setdefault("refresh_token", request.refresh_token)
187
+
188
+ return OAuth2Token.model_validate(data)
189
+
190
+ async def refresh_async(
191
+ self,
192
+ request: RefreshTokenRequest,
193
+ *,
194
+ cache_session: CacheAsyncSession | None = None,
195
+ ) -> OAuth2Token:
196
+ if not self.config.client_secret:
197
+ raise ValueError("client_secret required for standard client refresh")
198
+
199
+ payload: dict[str, str] = {
200
+ "grant_type": "refresh_token",
201
+ "client_id": self.config.client_id,
202
+ "client_secret": self.config.client_secret,
203
+ "refresh_token": request.refresh_token,
204
+ }
205
+ if request.scope:
206
+ payload["scope"] = request.scope
207
+
208
+ async with httpx.AsyncClient() as client:
209
+ resp = await client.post(
210
+ str(self.config.token_url),
211
+ data=payload,
212
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
213
+ timeout=10,
214
+ )
215
+ resp.raise_for_status()
216
+ data = resp.json()
217
+ data.setdefault("refresh_token", request.refresh_token)
218
+
219
+ return OAuth2Token.model_validate(data)
@@ -0,0 +1,54 @@
1
+ # ab_core/auth/oauth2/schema/authorize.py
2
+
3
+ from pydantic import AnyHttpUrl, BaseModel, Field, Literal
4
+
5
+ from ab_core.pkce.methods import PKCE, S256PKCE
6
+
7
+ from .client_type import OAuth2ClientType
8
+
9
+ # ---------- Requests ----------
10
+
11
+
12
+ class OAuth2BuildAuthorizeRequest(BaseModel):
13
+ type: Literal[OAuth2ClientType.STANDARD] = OAuth2ClientType.STANDARD
14
+ scope: str = "openid profile email"
15
+ response_type: str = "code"
16
+ state: str | None = None
17
+ state_ttl: int | None = None
18
+ extra_params: dict[str, str] | None = None
19
+
20
+
21
+ class PKCEBuildAuthorizeRequest(OAuth2BuildAuthorizeRequest):
22
+ type: Literal[OAuth2ClientType.PKCE] = OAuth2ClientType.PKCE
23
+ # If None, the PKCE client will default to S256
24
+ pkce: PKCE | None = Field(
25
+ default_factory=S256PKCE,
26
+ )
27
+
28
+
29
+ AuthorizeRequest = Annotated[
30
+ OAuth2BuildAuthorizeRequest | PKCEBuildAuthorizeRequest,
31
+ Field(discriminator="type"),
32
+ ]
33
+
34
+
35
+ # ---------- Responses ----------
36
+
37
+
38
+ class OAuth2AuthorizeResponse(BaseModel):
39
+ type: Literal[OAuth2ClientType.STANDARD] = OAuth2ClientType.STANDARD
40
+ url: AnyHttpUrl
41
+ state: str
42
+
43
+
44
+ class PKCEAuthorizeResponse(OAuth2AuthorizeResponse):
45
+ type: Literal[OAuth2ClientType.PKCE] = OAuth2ClientType.PKCE
46
+ code_verifier: str
47
+ code_challenge: str
48
+ code_challenge_method: str
49
+
50
+
51
+ AuthorizeResponse = Annotated[
52
+ OAuth2AuthorizeResponse | PKCEAuthorizeResponse,
53
+ Field(discriminator="type"),
54
+ ]
@@ -0,0 +1,6 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class OAuth2ClientType(StrEnum):
5
+ STANDARD = "STANDARD"
6
+ PKCE = "PKCE"
@@ -0,0 +1,23 @@
1
+ # ab_core/auth/oauth2/schema/exchange.py
2
+ from pydantic import AnyHttpUrl, BaseModel
3
+
4
+
5
+ class OAuth2ExchangeCodeRequest(BaseModel):
6
+ code: str
7
+
8
+
9
+ class OAuth2ExchangeFromRedirectUrlRequest(BaseModel):
10
+ redirect_url: AnyHttpUrl
11
+ expected_state: str | None = None
12
+ enforce_redirect_uri_match: bool = True
13
+
14
+
15
+ class PKCEExchangeCodeRequest(OAuth2ExchangeCodeRequest):
16
+ code_verifier: str | None = None
17
+ state: str | None = None
18
+ delete_after: bool = True
19
+
20
+
21
+ class PKCEExchangeFromRedirectUrlRequest(OAuth2ExchangeFromRedirectUrlRequest):
22
+ code_verifier: str | None = None
23
+ delete_after: bool = True
@@ -0,0 +1,9 @@
1
+ from pydantic import AnyHttpUrl, BaseModel
2
+
3
+
4
+ class OIDCConfig(BaseModel):
5
+ client_id: str
6
+ client_secret: str | None = None # for standard flow
7
+ redirect_uri: AnyHttpUrl
8
+ authorize_url: AnyHttpUrl
9
+ token_url: AnyHttpUrl
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel, SecretStr
2
+
3
+
4
+ class RefreshTokenRequest(BaseModel):
5
+ refresh_token: SecretStr
6
+ scope: str | None = None # optional; most providers ignore if omitted
@@ -0,0 +1,20 @@
1
+ from pydantic import BaseModel, SecretStr
2
+
3
+
4
+ class OAuth2Token(BaseModel):
5
+ """An OAuth2 token model with secrets stored as SecretStr."""
6
+
7
+ access_token: SecretStr
8
+ id_token: SecretStr | None = None
9
+ refresh_token: SecretStr | None = None
10
+ expires_in: int
11
+ scope: str | None = None
12
+ token_type: str
13
+
14
+
15
+ class OAuth2TokenExposed(BaseModel):
16
+ """An OAuth2 token model with secrets exposed as plain strings."""
17
+
18
+ access_token: str
19
+ id_token: str | None = None
20
+ refresh_token: str | None = None