mongo-oidc-human-callback 0.1.0__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.
- mongo_oidc_human_callback-0.1.0/.github/workflows/publish-pypi.yml +30 -0
- mongo_oidc_human_callback-0.1.0/.gitignore +17 -0
- mongo_oidc_human_callback-0.1.0/.python-version +1 -0
- mongo_oidc_human_callback-0.1.0/LICENSE +21 -0
- mongo_oidc_human_callback-0.1.0/PKG-INFO +109 -0
- mongo_oidc_human_callback-0.1.0/README.md +85 -0
- mongo_oidc_human_callback-0.1.0/mongo_oidc_human_callback/__init__.py +11 -0
- mongo_oidc_human_callback-0.1.0/mongo_oidc_human_callback/callback.py +364 -0
- mongo_oidc_human_callback-0.1.0/pyproject.toml +37 -0
- mongo_oidc_human_callback-0.1.0/uv.lock +94 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v7
|
|
20
|
+
with:
|
|
21
|
+
enable-cache: true
|
|
22
|
+
|
|
23
|
+
- name: Set up Python
|
|
24
|
+
run: uv python install
|
|
25
|
+
|
|
26
|
+
- name: Build
|
|
27
|
+
run: uv build
|
|
28
|
+
|
|
29
|
+
- name: Publish to PyPI
|
|
30
|
+
run: uv publish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Reouven Mimoun
|
|
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,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mongo-oidc-human-callback
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OIDC Human Callback for MongoDB: browser-based OAuth 2.0 Authorization Code Flow with PKCE (Azure AD and other OIDC providers).
|
|
5
|
+
Project-URL: Repository, https://github.com/mreouven/mongo-oidc-human-callback
|
|
6
|
+
Project-URL: Documentation, https://github.com/mreouven/mongo-oidc-human-callback#readme
|
|
7
|
+
Author: Reouven Mimoun
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: atlas,authentication,azure-ad,mongodb,oauth2,oidc,pkce
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: pymongo>=4.14
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# mongo-oidc-human-callback
|
|
26
|
+
|
|
27
|
+
OIDC Human Callback for MongoDB Atlas authentication: OAuth 2.0 Authorization Code flow with PKCE (Azure AD and other OIDC providers).
|
|
28
|
+
|
|
29
|
+
**Single dependency**: `pymongo` (stdlib for the rest: `urllib`, `logging`, `http.server`, etc.).
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install mongo-oidc-human-callback
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from pymongo import MongoClient
|
|
41
|
+
from mongo_oidc_human_callback import OIDCHumanCallback
|
|
42
|
+
|
|
43
|
+
# Connect with human callback (browser opens + PKCE)
|
|
44
|
+
client = MongoClient(
|
|
45
|
+
"mongodb+srv://<cluster>.mongodb.net/",
|
|
46
|
+
authMechanism="MONGODB-OIDC",
|
|
47
|
+
authMechanismProperties={"OIDC_CALLBACK": OIDCHumanCallback()},
|
|
48
|
+
)
|
|
49
|
+
# First access: browser opens to sign in (e.g. Azure AD)
|
|
50
|
+
client.admin.command("ping")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Callback options
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
OIDCHumanCallback(
|
|
57
|
+
redirect_path="redirect", # or "callback" depending on Azure AD config
|
|
58
|
+
port=27097, # local callback server port
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Features
|
|
63
|
+
|
|
64
|
+
- **PKCE** (Proof Key for Code Exchange)
|
|
65
|
+
- **Token caching** to avoid re-authenticating on each connection
|
|
66
|
+
- **Local HTTP server** to receive the OAuth redirect
|
|
67
|
+
- **Automatic browser opening**
|
|
68
|
+
- **Azure AD compatibility** (endpoints `/oauth2/v2.0/authorize` and `/oauth2/v2.0/token`)
|
|
69
|
+
|
|
70
|
+
## Azure AD configuration
|
|
71
|
+
|
|
72
|
+
In the Azure portal, for your app registration:
|
|
73
|
+
|
|
74
|
+
1. **Authentication** → Redirect URI: `http://localhost:27097/redirect` (or the `port`/`redirect_path` you use).
|
|
75
|
+
2. **API permissions** → Microsoft Graph (or target API): e.g. `openid`, `profile`, `email`.
|
|
76
|
+
|
|
77
|
+
## Publishing (PyPI)
|
|
78
|
+
|
|
79
|
+
### Via GitHub Actions
|
|
80
|
+
|
|
81
|
+
The `.github/workflows/publish-pypi.yml` workflow publishes to PyPI:
|
|
82
|
+
|
|
83
|
+
- **On each release**: create a release (tag) on GitHub → the workflow runs.
|
|
84
|
+
- **On demand**: Actions → Publish to PyPI → Run workflow.
|
|
85
|
+
|
|
86
|
+
**PyPI setup (trusted publishing, no secrets):**
|
|
87
|
+
|
|
88
|
+
1. PyPI → your project → **Publishing** → **Add a new trusted publisher**.
|
|
89
|
+
2. Enter **exactly** (as on GitHub):
|
|
90
|
+
- **Owner**: `mreouven` (your GitHub username, not display name)
|
|
91
|
+
- **Repository**: `mongo-oidc-human-callback`
|
|
92
|
+
- **Workflow**: `publish-pypi.yml`
|
|
93
|
+
3. Save. Future workflow runs will publish without `PYPI_API_TOKEN`.
|
|
94
|
+
|
|
95
|
+
If you get `invalid-publisher`, ensure Owner is the GitHub username of the repo (e.g. `mreouven`), not the org or display name.
|
|
96
|
+
|
|
97
|
+
**Alternative (API token):** add the `PYPI_API_TOKEN` secret in the repo (Settings → Secrets) and use a workflow that sets `TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}` if you prefer not to use trusted publishing.
|
|
98
|
+
|
|
99
|
+
### From your machine
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install build twine
|
|
103
|
+
python -m build
|
|
104
|
+
twine upload dist/*
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# mongo-oidc-human-callback
|
|
2
|
+
|
|
3
|
+
OIDC Human Callback for MongoDB Atlas authentication: OAuth 2.0 Authorization Code flow with PKCE (Azure AD and other OIDC providers).
|
|
4
|
+
|
|
5
|
+
**Single dependency**: `pymongo` (stdlib for the rest: `urllib`, `logging`, `http.server`, etc.).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install mongo-oidc-human-callback
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from pymongo import MongoClient
|
|
17
|
+
from mongo_oidc_human_callback import OIDCHumanCallback
|
|
18
|
+
|
|
19
|
+
# Connect with human callback (browser opens + PKCE)
|
|
20
|
+
client = MongoClient(
|
|
21
|
+
"mongodb+srv://<cluster>.mongodb.net/",
|
|
22
|
+
authMechanism="MONGODB-OIDC",
|
|
23
|
+
authMechanismProperties={"OIDC_CALLBACK": OIDCHumanCallback()},
|
|
24
|
+
)
|
|
25
|
+
# First access: browser opens to sign in (e.g. Azure AD)
|
|
26
|
+
client.admin.command("ping")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Callback options
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
OIDCHumanCallback(
|
|
33
|
+
redirect_path="redirect", # or "callback" depending on Azure AD config
|
|
34
|
+
port=27097, # local callback server port
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **PKCE** (Proof Key for Code Exchange)
|
|
41
|
+
- **Token caching** to avoid re-authenticating on each connection
|
|
42
|
+
- **Local HTTP server** to receive the OAuth redirect
|
|
43
|
+
- **Automatic browser opening**
|
|
44
|
+
- **Azure AD compatibility** (endpoints `/oauth2/v2.0/authorize` and `/oauth2/v2.0/token`)
|
|
45
|
+
|
|
46
|
+
## Azure AD configuration
|
|
47
|
+
|
|
48
|
+
In the Azure portal, for your app registration:
|
|
49
|
+
|
|
50
|
+
1. **Authentication** → Redirect URI: `http://localhost:27097/redirect` (or the `port`/`redirect_path` you use).
|
|
51
|
+
2. **API permissions** → Microsoft Graph (or target API): e.g. `openid`, `profile`, `email`.
|
|
52
|
+
|
|
53
|
+
## Publishing (PyPI)
|
|
54
|
+
|
|
55
|
+
### Via GitHub Actions
|
|
56
|
+
|
|
57
|
+
The `.github/workflows/publish-pypi.yml` workflow publishes to PyPI:
|
|
58
|
+
|
|
59
|
+
- **On each release**: create a release (tag) on GitHub → the workflow runs.
|
|
60
|
+
- **On demand**: Actions → Publish to PyPI → Run workflow.
|
|
61
|
+
|
|
62
|
+
**PyPI setup (trusted publishing, no secrets):**
|
|
63
|
+
|
|
64
|
+
1. PyPI → your project → **Publishing** → **Add a new trusted publisher**.
|
|
65
|
+
2. Enter **exactly** (as on GitHub):
|
|
66
|
+
- **Owner**: `mreouven` (your GitHub username, not display name)
|
|
67
|
+
- **Repository**: `mongo-oidc-human-callback`
|
|
68
|
+
- **Workflow**: `publish-pypi.yml`
|
|
69
|
+
3. Save. Future workflow runs will publish without `PYPI_API_TOKEN`.
|
|
70
|
+
|
|
71
|
+
If you get `invalid-publisher`, ensure Owner is the GitHub username of the repo (e.g. `mreouven`), not the org or display name.
|
|
72
|
+
|
|
73
|
+
**Alternative (API token):** add the `PYPI_API_TOKEN` secret in the repo (Settings → Secrets) and use a workflow that sets `TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}` if you prefer not to use trusted publishing.
|
|
74
|
+
|
|
75
|
+
### From your machine
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install build twine
|
|
79
|
+
python -m build
|
|
80
|
+
twine upload dist/*
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OIDC Human Callback for MongoDB authentication.
|
|
3
|
+
|
|
4
|
+
Browser-based OAuth 2.0 Authorization Code Flow with PKCE for MongoDB Atlas
|
|
5
|
+
(Azure AD and other OIDC providers).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from mongo_oidc_human_callback.callback import OIDCHumanCallback
|
|
9
|
+
|
|
10
|
+
__all__ = ["OIDCHumanCallback"]
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OIDC Human Callback implementation for MongoDB authentication.
|
|
3
|
+
|
|
4
|
+
OAuth 2.0 Authorization Code Flow with PKCE for MongoDB Atlas
|
|
5
|
+
(Azure AD and other OIDC providers).
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import http.server
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import secrets
|
|
13
|
+
import socketserver
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
import webbrowser
|
|
19
|
+
from threading import Event, Thread
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
from pymongo.auth_oidc import (
|
|
23
|
+
OIDCCallback,
|
|
24
|
+
OIDCCallbackContext,
|
|
25
|
+
OIDCCallbackResult,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Global cache for OIDC tokens to avoid re-authentication
|
|
31
|
+
_oidc_token_cache: Dict[str, Dict[str, Any]] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OIDCHumanCallback(OIDCCallback):
|
|
35
|
+
"""
|
|
36
|
+
OIDC Human Callback for browser-based authentication with PKCE.
|
|
37
|
+
|
|
38
|
+
Implements OAuth 2.0 Authorization Code Flow with PKCE.
|
|
39
|
+
|
|
40
|
+
Features:
|
|
41
|
+
- PKCE (Proof Key for Code Exchange)
|
|
42
|
+
- Token caching to avoid repeated authentication
|
|
43
|
+
- Local HTTP server for callback handling
|
|
44
|
+
- Browser auto-open
|
|
45
|
+
- Azure AD endpoint compatibility
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, redirect_path: str = "redirect", port: int = 27097):
|
|
49
|
+
"""
|
|
50
|
+
Initialize the callback handler.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
redirect_path: Path for the redirect URI (e.g. "redirect" or "callback").
|
|
54
|
+
port: Port for the local callback server.
|
|
55
|
+
"""
|
|
56
|
+
self._token_cache = _oidc_token_cache
|
|
57
|
+
self._redirect_path = redirect_path
|
|
58
|
+
self._port = port
|
|
59
|
+
|
|
60
|
+
def _get_cache_key(self, context: OIDCCallbackContext) -> str:
|
|
61
|
+
"""Generate a cache key from the context."""
|
|
62
|
+
if context.idp_info:
|
|
63
|
+
return (
|
|
64
|
+
f"{context.idp_info.issuer}:"
|
|
65
|
+
f"{context.idp_info.clientId}:"
|
|
66
|
+
f"{context.username}"
|
|
67
|
+
)
|
|
68
|
+
return f"default:{context.username}"
|
|
69
|
+
|
|
70
|
+
def _get_cached_token(self, cache_key: str) -> Optional[OIDCCallbackResult]:
|
|
71
|
+
"""Return a cached token if valid (with 60s buffer), else None."""
|
|
72
|
+
if cache_key not in self._token_cache:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
cached = self._token_cache[cache_key]
|
|
76
|
+
if cached.get("expires_at", 0) > time.time() + 60:
|
|
77
|
+
logger.debug("Using cached OIDC token for %s", cache_key)
|
|
78
|
+
return OIDCCallbackResult(
|
|
79
|
+
access_token=cached["access_token"],
|
|
80
|
+
expires_in_seconds=cached.get("expires_at", 0) - time.time(),
|
|
81
|
+
refresh_token=cached.get("refresh_token"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
del self._token_cache[cache_key]
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def _cache_token(
|
|
88
|
+
self,
|
|
89
|
+
cache_key: str,
|
|
90
|
+
token: str,
|
|
91
|
+
expires_in: Optional[float],
|
|
92
|
+
refresh_token: Optional[str],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Store a token with expiration."""
|
|
95
|
+
self._token_cache[cache_key] = {
|
|
96
|
+
"access_token": token,
|
|
97
|
+
"expires_at": time.time() + (expires_in or 3600),
|
|
98
|
+
"refresh_token": refresh_token,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def fetch(self, context: OIDCCallbackContext) -> OIDCCallbackResult:
|
|
102
|
+
"""
|
|
103
|
+
Fetch OIDC token via browser (PKCE flow).
|
|
104
|
+
|
|
105
|
+
1. Generate PKCE challenge
|
|
106
|
+
2. Start local HTTP server for callback
|
|
107
|
+
3. Open browser with authorization URL
|
|
108
|
+
4. Wait for authorization code
|
|
109
|
+
5. Exchange code for access token
|
|
110
|
+
6. Cache token for future use
|
|
111
|
+
"""
|
|
112
|
+
if not context.idp_info:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"Missing required IdP information in OIDC callback parameters"
|
|
115
|
+
)
|
|
116
|
+
if not context.idp_info.issuer or not context.idp_info.clientId:
|
|
117
|
+
raise RuntimeError("Missing issuer or clientId in IdP information")
|
|
118
|
+
|
|
119
|
+
cache_key = self._get_cache_key(context)
|
|
120
|
+
cached_token = self._get_cached_token(cache_key)
|
|
121
|
+
if cached_token:
|
|
122
|
+
logger.info("Using cached OIDC token (avoiding re-authentication)")
|
|
123
|
+
return cached_token
|
|
124
|
+
|
|
125
|
+
logger.info("No cached token found, starting new authentication flow...")
|
|
126
|
+
|
|
127
|
+
code_verifier = (
|
|
128
|
+
base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=")
|
|
129
|
+
)
|
|
130
|
+
code_challenge = (
|
|
131
|
+
base64.urlsafe_b64encode(
|
|
132
|
+
hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
|
133
|
+
)
|
|
134
|
+
.decode("utf-8")
|
|
135
|
+
.rstrip("=")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
auth_response: Dict[str, Any] = {
|
|
139
|
+
"code": None,
|
|
140
|
+
"error": None,
|
|
141
|
+
"error_description": None,
|
|
142
|
+
"received": Event(),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
redirect_uri = f"http://localhost:{self._port}/{self._redirect_path}"
|
|
146
|
+
|
|
147
|
+
class OIDCCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
148
|
+
"""HTTP handler for OAuth callback."""
|
|
149
|
+
|
|
150
|
+
def do_GET(self) -> None:
|
|
151
|
+
parsed_path = urllib.parse.urlparse(self.path)
|
|
152
|
+
params = urllib.parse.parse_qs(parsed_path.query)
|
|
153
|
+
|
|
154
|
+
if "code" in params:
|
|
155
|
+
auth_response["code"] = params["code"][0]
|
|
156
|
+
self.send_response(200)
|
|
157
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
158
|
+
self.end_headers()
|
|
159
|
+
html = _success_html()
|
|
160
|
+
self.wfile.write(html.encode("utf-8"))
|
|
161
|
+
auth_response["received"].set()
|
|
162
|
+
elif "error" in params:
|
|
163
|
+
auth_response["error"] = params["error"][0]
|
|
164
|
+
auth_response["error_description"] = params.get(
|
|
165
|
+
"error_description", [params["error"][0]]
|
|
166
|
+
)[0]
|
|
167
|
+
self.send_response(400)
|
|
168
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
169
|
+
self.end_headers()
|
|
170
|
+
html = _error_html(
|
|
171
|
+
auth_response["error"],
|
|
172
|
+
auth_response["error_description"],
|
|
173
|
+
)
|
|
174
|
+
self.wfile.write(html.encode("utf-8"))
|
|
175
|
+
auth_response["received"].set()
|
|
176
|
+
else:
|
|
177
|
+
self.send_response(400)
|
|
178
|
+
self.send_header("Content-type", "text/html; charset=utf-8")
|
|
179
|
+
self.end_headers()
|
|
180
|
+
self.wfile.write(_invalid_html().encode("utf-8"))
|
|
181
|
+
|
|
182
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
server: Optional[socketserver.TCPServer] = None
|
|
186
|
+
try:
|
|
187
|
+
server = socketserver.TCPServer(
|
|
188
|
+
("localhost", self._port), OIDCCallbackHandler
|
|
189
|
+
)
|
|
190
|
+
server_thread = Thread(target=server.serve_forever)
|
|
191
|
+
server_thread.daemon = True
|
|
192
|
+
server_thread.start()
|
|
193
|
+
|
|
194
|
+
logger.info("Local callback server started on port %s", self._port)
|
|
195
|
+
|
|
196
|
+
issuer = context.idp_info.issuer
|
|
197
|
+
client_id = context.idp_info.clientId
|
|
198
|
+
scopes = context.idp_info.requestScopes or []
|
|
199
|
+
|
|
200
|
+
if issuer.endswith("/v2.0"):
|
|
201
|
+
base_issuer = issuer.rsplit("/v2.0", 1)[0]
|
|
202
|
+
auth_url = f"{base_issuer}/oauth2/v2.0/authorize"
|
|
203
|
+
else:
|
|
204
|
+
auth_url = f"{issuer}/authorize"
|
|
205
|
+
|
|
206
|
+
params = {
|
|
207
|
+
"client_id": client_id,
|
|
208
|
+
"response_type": "code",
|
|
209
|
+
"redirect_uri": redirect_uri,
|
|
210
|
+
"response_mode": "query",
|
|
211
|
+
"scope": " ".join(scopes) if scopes else "openid profile email",
|
|
212
|
+
"prompt": "select_account",
|
|
213
|
+
"code_challenge": code_challenge,
|
|
214
|
+
"code_challenge_method": "S256",
|
|
215
|
+
}
|
|
216
|
+
full_auth_url = f"{auth_url}?{urllib.parse.urlencode(params)}"
|
|
217
|
+
|
|
218
|
+
logger.info("Opening browser for authentication...")
|
|
219
|
+
webbrowser.open(full_auth_url)
|
|
220
|
+
logger.info("Waiting for authentication to complete...")
|
|
221
|
+
|
|
222
|
+
if not auth_response["received"].wait(timeout=context.timeout_seconds):
|
|
223
|
+
raise RuntimeError("Authentication timeout - no response received")
|
|
224
|
+
|
|
225
|
+
if auth_response["error"]:
|
|
226
|
+
error_msg = auth_response.get("error_description") or auth_response[
|
|
227
|
+
"error"
|
|
228
|
+
]
|
|
229
|
+
raise RuntimeError(f"Authentication failed: {error_msg}")
|
|
230
|
+
|
|
231
|
+
if not auth_response["code"]:
|
|
232
|
+
raise RuntimeError("No authorization code received")
|
|
233
|
+
|
|
234
|
+
logger.info("Authorization code received")
|
|
235
|
+
|
|
236
|
+
if issuer.endswith("/v2.0"):
|
|
237
|
+
base_issuer = issuer.rsplit("/v2.0", 1)[0]
|
|
238
|
+
token_url = f"{base_issuer}/oauth2/v2.0/token"
|
|
239
|
+
else:
|
|
240
|
+
token_url = f"{issuer}/token"
|
|
241
|
+
|
|
242
|
+
token_data = urllib.parse.urlencode(
|
|
243
|
+
{
|
|
244
|
+
"grant_type": "authorization_code",
|
|
245
|
+
"code": auth_response["code"],
|
|
246
|
+
"redirect_uri": redirect_uri,
|
|
247
|
+
"client_id": client_id,
|
|
248
|
+
"code_verifier": code_verifier,
|
|
249
|
+
}
|
|
250
|
+
).encode("utf-8")
|
|
251
|
+
|
|
252
|
+
logger.info("Exchanging authorization code for access token (PKCE)...")
|
|
253
|
+
|
|
254
|
+
req = urllib.request.Request(
|
|
255
|
+
token_url,
|
|
256
|
+
data=token_data,
|
|
257
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
258
|
+
method="POST",
|
|
259
|
+
)
|
|
260
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
261
|
+
token_response = json.loads(resp.read().decode("utf-8"))
|
|
262
|
+
|
|
263
|
+
access_token = token_response.get("access_token")
|
|
264
|
+
refresh_token = token_response.get("refresh_token")
|
|
265
|
+
expires_in = token_response.get("expires_in")
|
|
266
|
+
|
|
267
|
+
if not access_token:
|
|
268
|
+
raise RuntimeError("No access token in response")
|
|
269
|
+
|
|
270
|
+
logger.info("Access token obtained successfully")
|
|
271
|
+
|
|
272
|
+
self._cache_token(
|
|
273
|
+
cache_key,
|
|
274
|
+
access_token,
|
|
275
|
+
float(expires_in) if expires_in else None,
|
|
276
|
+
refresh_token,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return OIDCCallbackResult(
|
|
280
|
+
access_token=access_token,
|
|
281
|
+
expires_in_seconds=float(expires_in) if expires_in else None,
|
|
282
|
+
refresh_token=refresh_token or context.refresh_token,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
except urllib.error.HTTPError as e:
|
|
286
|
+
body = e.read().decode("utf-8", errors="replace")
|
|
287
|
+
logger.error("Token exchange failed: %s - %s", e.code, body[:200])
|
|
288
|
+
raise RuntimeError(f"Token exchange failed: {e.code} - {body[:200]}") from e
|
|
289
|
+
except urllib.error.URLError as e:
|
|
290
|
+
logger.error("Token exchange request failed: %s", e)
|
|
291
|
+
raise RuntimeError(f"Token exchange failed: {e}") from e
|
|
292
|
+
except json.JSONDecodeError as e:
|
|
293
|
+
logger.error("Failed to parse token response: %s", e)
|
|
294
|
+
raise RuntimeError("Invalid token response format") from e
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error("OIDC authentication failed: %s", e)
|
|
297
|
+
raise
|
|
298
|
+
finally:
|
|
299
|
+
if server:
|
|
300
|
+
server.shutdown()
|
|
301
|
+
server.server_close()
|
|
302
|
+
logger.debug("Callback server closed")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _success_html() -> str:
|
|
306
|
+
return """
|
|
307
|
+
<html>
|
|
308
|
+
<head>
|
|
309
|
+
<title>Authentication Successful</title>
|
|
310
|
+
<style>
|
|
311
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f0f0f0; }
|
|
312
|
+
.container { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; }
|
|
313
|
+
h1 { color: #28a745; }
|
|
314
|
+
.icon { font-size: 64px; margin-bottom: 20px; }
|
|
315
|
+
</style>
|
|
316
|
+
</head>
|
|
317
|
+
<body>
|
|
318
|
+
<div class="container">
|
|
319
|
+
<div class="icon">✓</div>
|
|
320
|
+
<h1>Authentication Successful!</h1>
|
|
321
|
+
<p>You can close this window and return to your application.</p>
|
|
322
|
+
</div>
|
|
323
|
+
</body>
|
|
324
|
+
</html>
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _error_html(error: str, description: str) -> str:
|
|
329
|
+
return f"""
|
|
330
|
+
<html>
|
|
331
|
+
<head>
|
|
332
|
+
<title>Authentication Failed</title>
|
|
333
|
+
<style>
|
|
334
|
+
body {{ font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f0f0f0; }}
|
|
335
|
+
.container {{ background: white; padding: 40px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; }}
|
|
336
|
+
h1 {{ color: #dc3545; }}
|
|
337
|
+
.icon {{ font-size: 64px; margin-bottom: 20px; }}
|
|
338
|
+
.error {{ color: #666; margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px; }}
|
|
339
|
+
</style>
|
|
340
|
+
</head>
|
|
341
|
+
<body>
|
|
342
|
+
<div class="container">
|
|
343
|
+
<div class="icon">✗</div>
|
|
344
|
+
<h1>Authentication Failed</h1>
|
|
345
|
+
<div class="error">
|
|
346
|
+
<strong>Error:</strong> {error}<br>
|
|
347
|
+
<strong>Description:</strong> {description}
|
|
348
|
+
</div>
|
|
349
|
+
<p style="margin-top: 20px;">Please close this window and try again.</p>
|
|
350
|
+
</div>
|
|
351
|
+
</body>
|
|
352
|
+
</html>
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _invalid_html() -> str:
|
|
357
|
+
return """
|
|
358
|
+
<html>
|
|
359
|
+
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
|
|
360
|
+
<h1>Invalid Request</h1>
|
|
361
|
+
<p>Missing required parameters.</p>
|
|
362
|
+
</body>
|
|
363
|
+
</html>
|
|
364
|
+
"""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mongo-oidc-human-callback"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OIDC Human Callback for MongoDB: browser-based OAuth 2.0 Authorization Code Flow with PKCE (Azure AD and other OIDC providers)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Reouven Mimoun" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mongodb", "oidc", "oauth2", "pkce", "azure-ad", "atlas", "authentication"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Database",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"pymongo>=4.14",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/mreouven/mongo-oidc-human-callback"
|
|
34
|
+
Documentation = "https://github.com/mreouven/mongo-oidc-human-callback#readme"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["mongo_oidc_human_callback"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 1
|
|
3
|
+
requires-python = ">=3.10"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "dnspython"
|
|
7
|
+
version = "2.8.0"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "mongo-oidc-human-callback"
|
|
16
|
+
version = "0.1.0"
|
|
17
|
+
source = { editable = "." }
|
|
18
|
+
dependencies = [
|
|
19
|
+
{ name = "pymongo" },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[package.metadata]
|
|
23
|
+
requires-dist = [{ name = "pymongo", specifier = ">=4.14" }]
|
|
24
|
+
|
|
25
|
+
[[package]]
|
|
26
|
+
name = "pymongo"
|
|
27
|
+
version = "4.16.0"
|
|
28
|
+
source = { registry = "https://pypi.org/simple" }
|
|
29
|
+
dependencies = [
|
|
30
|
+
{ name = "dnspython" },
|
|
31
|
+
]
|
|
32
|
+
sdist = { url = "https://files.pythonhosted.org/packages/65/9c/a4895c4b785fc9865a84a56e14b5bd21ca75aadc3dab79c14187cdca189b/pymongo-4.16.0.tar.gz", hash = "sha256:8ba8405065f6e258a6f872fe62d797a28f383a12178c7153c01ed04e845c600c", size = 2495323 }
|
|
33
|
+
wheels = [
|
|
34
|
+
{ url = "https://files.pythonhosted.org/packages/4d/93/c36c0998dd91ad8b5031d2e77a903d5cd705b5ba05ca92bcc8731a2c3a8d/pymongo-4.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ed162b2227f98d5b270ecbe1d53be56c8c81db08a1a8f5f02d89c7bb4d19591d", size = 807993 },
|
|
35
|
+
{ url = "https://files.pythonhosted.org/packages/f3/96/d2117d792fa9fedb2f6ccf0608db31f851e8382706d7c3c88c6ac92cc958/pymongo-4.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a9390dce61d705a88218f0d7b54d7e1fa1b421da8129fc7c009e029a9a6b81e", size = 808355 },
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/ae/2e/e79b7b86c0dd6323d0985c201583c7921d67b842b502aae3f3327cbe3935/pymongo-4.16.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:92a232af9927710de08a6c16a9710cc1b175fb9179c0d946cd4e213b92b2a69a", size = 1182337 },
|
|
37
|
+
{ url = "https://files.pythonhosted.org/packages/7b/82/07ec9966381c57d941fddc52637e9c9653e63773be410bd8605f74683084/pymongo-4.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d79aa147ce86aef03079096d83239580006ffb684eead593917186aee407767", size = 1200928 },
|
|
38
|
+
{ url = "https://files.pythonhosted.org/packages/44/15/9d45e3cc6fa428b0a3600b0c1c86b310f28c91251c41493460695ab40b6b/pymongo-4.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19a1c96e7f39c7a59a9cfd4d17920cf9382f6f684faeff4649bf587dc59f8edc", size = 1239418 },
|
|
39
|
+
{ url = "https://files.pythonhosted.org/packages/c8/b3/f35ee51e2a3f05f673ad4f5e803ae1284c42f4413e8d121c4958f1af4eb9/pymongo-4.16.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efe020c46ce3c3a89af6baec6569635812129df6fb6cf76d4943af3ba6ee2069", size = 1229045 },
|
|
40
|
+
{ url = "https://files.pythonhosted.org/packages/18/2d/1688b88d7c0a5c01da8c703dea831419435d9ce67c6ddbb0ac629c9c72d2/pymongo-4.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dc2c00bed568732b89e211b6adca389053d5e6d2d5a8979e80b813c3ec4d1f9", size = 1196517 },
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/e6/c6/e89db0f23bd20757b627a5d8c73a609ffd6741887b9004ab229208a79764/pymongo-4.16.0-cp310-cp310-win32.whl", hash = "sha256:5b9c6d689bbe5beb156374508133218610e14f8c81e35bc17d7a14e30ab593e6", size = 794911 },
|
|
42
|
+
{ url = "https://files.pythonhosted.org/packages/37/54/e00a5e517153f310a33132375159e42dceb12bee45b51b35aa0df14f1866/pymongo-4.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:2290909275c9b8f637b0a92eb9b89281e18a72922749ebb903403ab6cc7da914", size = 804801 },
|
|
43
|
+
{ url = "https://files.pythonhosted.org/packages/e5/0a/2572faf89195a944c99c6d756227019c8c5f4b5658ecc261c303645dfe69/pymongo-4.16.0-cp310-cp310-win_arm64.whl", hash = "sha256:6af1aaa26f0835175d2200e62205b78e7ec3ffa430682e322cc91aaa1a0dbf28", size = 797579 },
|
|
44
|
+
{ url = "https://files.pythonhosted.org/packages/e6/3a/907414a763c4270b581ad6d960d0c6221b74a70eda216a1fdd8fa82ba89f/pymongo-4.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f2077ec24e2f1248f9cac7b9a2dfb894e50cc7939fcebfb1759f99304caabef", size = 862561 },
|
|
45
|
+
{ url = "https://files.pythonhosted.org/packages/8c/58/787d8225dd65cb2383c447346ea5e200ecfde89962d531111521e3b53018/pymongo-4.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d4f7ba040f72a9f43a44059872af5a8c8c660aa5d7f90d5344f2ed1c3c02721", size = 862923 },
|
|
46
|
+
{ url = "https://files.pythonhosted.org/packages/5d/a7/cc2865aae32bc77ade7b35f957a58df52680d7f8506f93c6edbf458e5738/pymongo-4.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a0f73af1ea56c422b2dcfc0437459148a799ef4231c6aee189d2d4c59d6728f", size = 1426779 },
|
|
47
|
+
{ url = "https://files.pythonhosted.org/packages/81/25/3e96eb7998eec05382174da2fefc58d28613f46bbdf821045539d0ed60ab/pymongo-4.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa30cd16ddd2f216d07ba01d9635c873e97ddb041c61cf0847254edc37d1c60e", size = 1454207 },
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/86/7b/8e817a7df8c5d565d39dd4ca417a5e0ef46cc5cc19aea9405f403fec6449/pymongo-4.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d638b0b1b294d95d0fdc73688a3b61e05cc4188872818cd240d51460ccabcb5", size = 1511654 },
|
|
49
|
+
{ url = "https://files.pythonhosted.org/packages/39/7a/50c4d075ccefcd281cdcfccc5494caa5665b096b85e65a5d6afabb80e09e/pymongo-4.16.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:21d02cc10a158daa20cb040985e280e7e439832fc6b7857bff3d53ef6914ad50", size = 1496794 },
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/0f/cd/ebdc1aaca5deeaf47310c369ef4083e8550e04e7bf7e3752cfb7d95fcdb8/pymongo-4.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fbb8d3552c2ad99d9e236003c0b5f96d5f05e29386ba7abae73949bfebc13dd", size = 1448371 },
|
|
51
|
+
{ url = "https://files.pythonhosted.org/packages/3d/c9/50fdd78c37f68ea49d590c027c96919fbccfd98f3a4cb39f84f79970bd37/pymongo-4.16.0-cp311-cp311-win32.whl", hash = "sha256:be1099a8295b1a722d03fb7b48be895d30f4301419a583dcf50e9045968a041c", size = 841024 },
|
|
52
|
+
{ url = "https://files.pythonhosted.org/packages/4a/dd/a3aa1ade0cf9980744db703570afac70a62c85b432c391dea0577f6da7bb/pymongo-4.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:61567f712bda04c7545a037e3284b4367cad8d29b3dec84b4bf3b2147020a75b", size = 855838 },
|
|
53
|
+
{ url = "https://files.pythonhosted.org/packages/bf/10/9ad82593ccb895e8722e4884bad4c5ce5e8ff6683b740d7823a6c2bcfacf/pymongo-4.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:c53338613043038005bf2e41a2fafa08d29cdbc0ce80891b5366c819456c1ae9", size = 845007 },
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/6a/03/6dd7c53cbde98de469a3e6fb893af896dca644c476beb0f0c6342bcc368b/pymongo-4.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd4911c40a43a821dfd93038ac824b756b6e703e26e951718522d29f6eb166a8", size = 917619 },
|
|
55
|
+
{ url = "https://files.pythonhosted.org/packages/73/e1/328915f2734ea1f355dc9b0e98505ff670f5fab8be5e951d6ed70971c6aa/pymongo-4.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25a6b03a68f9907ea6ec8bc7cf4c58a1b51a18e23394f962a6402f8e46d41211", size = 917364 },
|
|
56
|
+
{ url = "https://files.pythonhosted.org/packages/41/fe/4769874dd9812a1bc2880a9785e61eba5340da966af888dd430392790ae0/pymongo-4.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91ac0cb0fe2bf17616c2039dac88d7c9a5088f5cb5829b27c9d250e053664d31", size = 1686901 },
|
|
57
|
+
{ url = "https://files.pythonhosted.org/packages/fa/8d/15707b9669fdc517bbc552ac60da7124dafe7ac1552819b51e97ed4038b4/pymongo-4.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf0ec79e8ca7077f455d14d915d629385153b6a11abc0b93283ed73a8013e376", size = 1723034 },
|
|
58
|
+
{ url = "https://files.pythonhosted.org/packages/5b/af/3d5d16ff11d447d40c1472da1b366a31c7380d7ea2922a449c7f7f495567/pymongo-4.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d0082631a7510318befc2b4fdab140481eb4b9dd62d9245e042157085da2a70", size = 1797161 },
|
|
59
|
+
{ url = "https://files.pythonhosted.org/packages/fb/04/725ab8664eeec73ec125b5a873448d80f5d8cf2750aaaf804cbc538a50a5/pymongo-4.16.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85dc2f3444c346ea019a371e321ac868a4fab513b7a55fe368f0cc78de8177cc", size = 1780938 },
|
|
60
|
+
{ url = "https://files.pythonhosted.org/packages/22/50/dd7e9095e1ca35f93c3c844c92eb6eb0bc491caeb2c9bff3b32fe3c9b18f/pymongo-4.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbf3c14de75a20cc3c30bf0c6527157224a93dfb605838eabb1a2ee3be008d", size = 1714342 },
|
|
61
|
+
{ url = "https://files.pythonhosted.org/packages/03/c9/542776987d5c31ae8e93e92680ea2b6e5a2295f398b25756234cabf38a39/pymongo-4.16.0-cp312-cp312-win32.whl", hash = "sha256:60307bb91e0ab44e560fe3a211087748b2b5f3e31f403baf41f5b7b0a70bd104", size = 887868 },
|
|
62
|
+
{ url = "https://files.pythonhosted.org/packages/2e/d4/b4045a7ccc5680fb496d01edf749c7a9367cc8762fbdf7516cf807ef679b/pymongo-4.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f513b2c6c0d5c491f478422f6b5b5c27ac1af06a54c93ef8631806f7231bd92e", size = 907554 },
|
|
63
|
+
{ url = "https://files.pythonhosted.org/packages/60/4c/33f75713d50d5247f2258405142c0318ff32c6f8976171c4fcae87a9dbdf/pymongo-4.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:dfc320f08ea9a7ec5b2403dc4e8150636f0d6150f4b9792faaae539c88e7db3b", size = 892971 },
|
|
64
|
+
{ url = "https://files.pythonhosted.org/packages/47/84/148d8b5da8260f4679d6665196ae04ab14ffdf06f5fe670b0ab11942951f/pymongo-4.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d15f060bc6d0964a8bb70aba8f0cb6d11ae99715438f640cff11bbcf172eb0e8", size = 972009 },
|
|
65
|
+
{ url = "https://files.pythonhosted.org/packages/1e/5e/9f3a8daf583d0adaaa033a3e3e58194d2282737dc164014ff33c7a081103/pymongo-4.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a19ea46a0fe71248965305a020bc076a163311aefbaa1d83e47d06fa30ac747", size = 971784 },
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/ad/f2/b6c24361fcde24946198573c0176406bfd5f7b8538335f3d939487055322/pymongo-4.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:311d4549d6bf1f8c61d025965aebb5ba29d1481dc6471693ab91610aaffbc0eb", size = 1947174 },
|
|
67
|
+
{ url = "https://files.pythonhosted.org/packages/47/1a/8634192f98cf740b3d174e1018dd0350018607d5bd8ac35a666dc49c732b/pymongo-4.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ffb728d92dd5b09fc034ed91acf5595657c7ca17d4cf3751322cd554153c17", size = 1991727 },
|
|
68
|
+
{ url = "https://files.pythonhosted.org/packages/5a/2f/0c47ac84572b28e23028a23a3798a1f725e1c23b0cf1c1424678d16aff42/pymongo-4.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:acda193f440dd88c2023cb00aa8bd7b93a9df59978306d14d87a8b12fe426b05", size = 2082497 },
|
|
69
|
+
{ url = "https://files.pythonhosted.org/packages/ba/57/9f46ef9c862b2f0cf5ce798f3541c201c574128d31ded407ba4b3918d7b6/pymongo-4.16.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d9fdb386cf958e6ef6ff537d6149be7edb76c3268cd6833e6c36aa447e4443f", size = 2064947 },
|
|
70
|
+
{ url = "https://files.pythonhosted.org/packages/b8/56/5421c0998f38e32288100a07f6cb2f5f9f352522157c901910cb2927e211/pymongo-4.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91899dd7fb9a8c50f09c3c1cf0cb73bfbe2737f511f641f19b9650deb61c00ca", size = 1980478 },
|
|
71
|
+
{ url = "https://files.pythonhosted.org/packages/92/93/bfc448d025e12313a937d6e1e0101b50cc9751636b4b170e600fe3203063/pymongo-4.16.0-cp313-cp313-win32.whl", hash = "sha256:2cd60cd1e05de7f01927f8e25ca26b3ea2c09de8723241e5d3bcfdc70eaff76b", size = 934672 },
|
|
72
|
+
{ url = "https://files.pythonhosted.org/packages/96/10/12710a5e01218d50c3dd165fd72c5ed2699285f77348a3b1a119a191d826/pymongo-4.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ead8a0050c53eaa55935895d6919d393d0328ec24b2b9115bdbe881aa222673", size = 959237 },
|
|
73
|
+
{ url = "https://files.pythonhosted.org/packages/0c/56/d288bcd1d05bc17ec69df1d0b1d67bc710c7c5dbef86033a5a4d2e2b08e6/pymongo-4.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:dbbc5b254c36c37d10abb50e899bc3939bbb7ab1e7c659614409af99bd3e7675", size = 940909 },
|
|
74
|
+
{ url = "https://files.pythonhosted.org/packages/30/9e/4d343f8d0512002fce17915a89477b9f916bda1205729e042d8f23acf194/pymongo-4.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a254d49a9ffe9d7f888e3c677eed3729b14ce85abb08cd74732cead6ccc3c66", size = 1026634 },
|
|
75
|
+
{ url = "https://files.pythonhosted.org/packages/c3/e3/341f88c5535df40c0450fda915f582757bb7d988cdfc92990a5e27c4c324/pymongo-4.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1bf44e13cf2d44d2ea2e928a8140d5d667304abe1a61c4d55b4906f389fbe64", size = 1026252 },
|
|
76
|
+
{ url = "https://files.pythonhosted.org/packages/af/64/9471b22eb98f0a2ca0b8e09393de048502111b2b5b14ab1bd9e39708aab5/pymongo-4.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f1c5f1f818b669875d191323a48912d3fcd2e4906410e8297bb09ac50c4d5ccc", size = 2207399 },
|
|
77
|
+
{ url = "https://files.pythonhosted.org/packages/87/ac/47c4d50b25a02f21764f140295a2efaa583ee7f17992a5e5fa542b3a690f/pymongo-4.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77cfd37a43a53b02b7bd930457c7994c924ad8bbe8dff91817904bcbf291b371", size = 2260595 },
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/ee/1b/0ce1ce9dd036417646b2fe6f63b58127acff3cf96eeb630c34ec9cd675ff/pymongo-4.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36ef2fee50eee669587d742fb456e349634b4fcf8926208766078b089054b24b", size = 2366958 },
|
|
79
|
+
{ url = "https://files.pythonhosted.org/packages/3e/3c/a5a17c0d413aa9d6c17bc35c2b472e9e79cda8068ba8e93433b5f43028e9/pymongo-4.16.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55f8d5a6fe2fa0b823674db2293f92d74cd5f970bc0360f409a1fc21003862d3", size = 2346081 },
|
|
80
|
+
{ url = "https://files.pythonhosted.org/packages/65/19/f815533d1a88fb8a3b6c6e895bb085ffdae68ccb1e6ed7102202a307f8e2/pymongo-4.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9caacac0dd105e2555521002e2d17afc08665187017b466b5753e84c016628e6", size = 2246053 },
|
|
81
|
+
{ url = "https://files.pythonhosted.org/packages/c6/88/4be3ec78828dc64b212c123114bd6ae8db5b7676085a7b43cc75d0131bd2/pymongo-4.16.0-cp314-cp314-win32.whl", hash = "sha256:c789236366525c3ee3cd6e4e450a9ff629a7d1f4d88b8e18a0aea0615fd7ecf8", size = 989461 },
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/af/5a/ab8d5af76421b34db483c9c8ebc3a2199fb80ae63dc7e18f4cf1df46306a/pymongo-4.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b0714d7764efb29bf9d3c51c964aed7c4c7237b341f9346f15ceaf8321fdb35", size = 1017803 },
|
|
83
|
+
{ url = "https://files.pythonhosted.org/packages/f6/f4/98d68020728ac6423cf02d17cfd8226bf6cce5690b163d30d3f705e8297e/pymongo-4.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:12762e7cc0f8374a8cae3b9f9ed8dabb5d438c7b33329232dd9b7de783454033", size = 997184 },
|
|
84
|
+
{ url = "https://files.pythonhosted.org/packages/50/00/dc3a271daf06401825b9c1f4f76f018182c7738281ea54b9762aea0560c1/pymongo-4.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1c01e8a7cd0ea66baf64a118005535ab5bf9f9eb63a1b50ac3935dccf9a54abe", size = 1083303 },
|
|
85
|
+
{ url = "https://files.pythonhosted.org/packages/b8/4b/b5375ee21d12eababe46215011ebc63801c0d2c5ffdf203849d0d79f9852/pymongo-4.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c4872299ebe315a79f7f922051061634a64fda95b6b17677ba57ef00b2ba2a4", size = 1083233 },
|
|
86
|
+
{ url = "https://files.pythonhosted.org/packages/ee/e3/52efa3ca900622c7dcb56c5e70f15c906816d98905c22d2ee1f84d9a7b60/pymongo-4.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78037d02389745e247fe5ab0bcad5d1ab30726eaac3ad79219c7d6bbb07eec53", size = 2527438 },
|
|
87
|
+
{ url = "https://files.pythonhosted.org/packages/cb/96/43b1be151c734e7766c725444bcbfa1de6b60cc66bfb406203746839dd25/pymongo-4.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c126fb72be2518395cc0465d4bae03125119136462e1945aea19840e45d89cfc", size = 2600399 },
|
|
88
|
+
{ url = "https://files.pythonhosted.org/packages/e7/62/fa64a5045dfe3a1cd9217232c848256e7bc0136cffb7da4735c5e0d30e40/pymongo-4.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3867dc225d9423c245a51eaac2cfcd53dde8e0a8d8090bb6aed6e31bd6c2d4f", size = 2720960 },
|
|
89
|
+
{ url = "https://files.pythonhosted.org/packages/54/7b/01577eb97e605502821273a5bc16ce0fb0be5c978fe03acdbff471471202/pymongo-4.16.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f25001a955073b80510c0c3db0e043dbbc36904fd69e511c74e3d8640b8a5111", size = 2699344 },
|
|
90
|
+
{ url = "https://files.pythonhosted.org/packages/55/68/6ef6372d516f703479c3b6cbbc45a5afd307173b1cbaccd724e23919bb1a/pymongo-4.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d9885aad05f82fd7ea0c9ca505d60939746b39263fa273d0125170da8f59098", size = 2577133 },
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/15/c7/b5337093bb01da852f945802328665f85f8109dbe91d81ea2afe5ff059b9/pymongo-4.16.0-cp314-cp314t-win32.whl", hash = "sha256:948152b30eddeae8355495f9943a3bf66b708295c0b9b6f467de1c620f215487", size = 1040560 },
|
|
92
|
+
{ url = "https://files.pythonhosted.org/packages/96/8c/5b448cd1b103f3889d5713dda37304c81020ff88e38a826e8a75ddff4610/pymongo-4.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f6e42c1bc985d9beee884780ae6048790eb4cd565c46251932906bdb1630034a", size = 1075081 },
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725 },
|
|
94
|
+
]
|