3tears-iam 0.20.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.
- 3tears_iam-0.20.0/.gitignore +220 -0
- 3tears_iam-0.20.0/LICENSE +21 -0
- 3tears_iam-0.20.0/PKG-INFO +141 -0
- 3tears_iam-0.20.0/README.md +105 -0
- 3tears_iam-0.20.0/pyproject.toml +107 -0
- 3tears_iam-0.20.0/src/threetears/iam/__init__.py +20 -0
- 3tears_iam-0.20.0/src/threetears/iam/_digest.py +33 -0
- 3tears_iam-0.20.0/src/threetears/iam/apikeys.py +89 -0
- 3tears_iam-0.20.0/src/threetears/iam/breach.py +185 -0
- 3tears_iam-0.20.0/src/threetears/iam/claim_mapping.py +129 -0
- 3tears_iam-0.20.0/src/threetears/iam/clientip.py +204 -0
- 3tears_iam-0.20.0/src/threetears/iam/dpop.py +179 -0
- 3tears_iam-0.20.0/src/threetears/iam/github.py +279 -0
- 3tears_iam-0.20.0/src/threetears/iam/oauth_state.py +324 -0
- 3tears_iam-0.20.0/src/threetears/iam/oidc.py +317 -0
- 3tears_iam-0.20.0/src/threetears/iam/passwords.py +294 -0
- 3tears_iam-0.20.0/src/threetears/iam/pkce.py +129 -0
- 3tears_iam-0.20.0/src/threetears/iam/py.typed +0 -0
- 3tears_iam-0.20.0/src/threetears/iam/rotation.py +328 -0
- 3tears_iam-0.20.0/src/threetears/iam/saml.py +255 -0
- 3tears_iam-0.20.0/src/threetears/iam/stepup.py +105 -0
- 3tears_iam-0.20.0/src/threetears/iam/stores/__init__.py +59 -0
- 3tears_iam-0.20.0/src/threetears/iam/stores/base.py +164 -0
- 3tears_iam-0.20.0/src/threetears/iam/stores/memory.py +125 -0
- 3tears_iam-0.20.0/src/threetears/iam/stores/nats_kv.py +340 -0
- 3tears_iam-0.20.0/src/threetears/iam/stores/postgres.py +234 -0
- 3tears_iam-0.20.0/src/threetears/iam/tokens.py +652 -0
- 3tears_iam-0.20.0/src/threetears/iam/totp.py +141 -0
- 3tears_iam-0.20.0/src/threetears/iam/webauthn.py +75 -0
- 3tears_iam-0.20.0/tests/enforcement/__init__.py +0 -0
- 3tears_iam-0.20.0/tests/enforcement/test_tokens_alg_pinning.py +53 -0
- 3tears_iam-0.20.0/tests/unit/test_apikeys.py +75 -0
- 3tears_iam-0.20.0/tests/unit/test_breach.py +102 -0
- 3tears_iam-0.20.0/tests/unit/test_claim_mapping.py +109 -0
- 3tears_iam-0.20.0/tests/unit/test_clientip.py +159 -0
- 3tears_iam-0.20.0/tests/unit/test_dpop.py +323 -0
- 3tears_iam-0.20.0/tests/unit/test_github.py +183 -0
- 3tears_iam-0.20.0/tests/unit/test_oauth_state.py +246 -0
- 3tears_iam-0.20.0/tests/unit/test_oidc.py +217 -0
- 3tears_iam-0.20.0/tests/unit/test_passwords.py +166 -0
- 3tears_iam-0.20.0/tests/unit/test_pkce.py +107 -0
- 3tears_iam-0.20.0/tests/unit/test_rotation.py +522 -0
- 3tears_iam-0.20.0/tests/unit/test_saml.py +200 -0
- 3tears_iam-0.20.0/tests/unit/test_stepup.py +100 -0
- 3tears_iam-0.20.0/tests/unit/test_stores_memory.py +171 -0
- 3tears_iam-0.20.0/tests/unit/test_stores_nats_kv.py +474 -0
- 3tears_iam-0.20.0/tests/unit/test_stores_postgres.py +239 -0
- 3tears_iam-0.20.0/tests/unit/test_tokens.py +360 -0
- 3tears_iam-0.20.0/tests/unit/test_totp.py +144 -0
- 3tears_iam-0.20.0/tests/unit/test_webauthn.py +69 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
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
|
+
# Anchored: these name top-level build output. Unanchored, `lib/` matches at ANY depth --
|
|
18
|
+
# it swallowed a vendored `.../pako/lib/` tree, and hatchling reads this file with its own
|
|
19
|
+
# matcher that does NOT honour `!` re-inclusion, so the miss reached built artifacts.
|
|
20
|
+
/lib/
|
|
21
|
+
/lib64/
|
|
22
|
+
parts/
|
|
23
|
+
sdist/
|
|
24
|
+
var/
|
|
25
|
+
wheels/
|
|
26
|
+
share/python-wheels/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.installed.cfg
|
|
29
|
+
*.egg
|
|
30
|
+
MANIFEST
|
|
31
|
+
|
|
32
|
+
# PyInstaller
|
|
33
|
+
# Usually these files are written by a python script from a template
|
|
34
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
35
|
+
*.manifest
|
|
36
|
+
*.spec
|
|
37
|
+
|
|
38
|
+
# Installer logs
|
|
39
|
+
pip-log.txt
|
|
40
|
+
pip-delete-this-directory.txt
|
|
41
|
+
|
|
42
|
+
# Unit test / coverage reports
|
|
43
|
+
htmlcov/
|
|
44
|
+
.tox/
|
|
45
|
+
.nox/
|
|
46
|
+
.coverage
|
|
47
|
+
.coverage.*
|
|
48
|
+
.cache
|
|
49
|
+
nosetests.xml
|
|
50
|
+
coverage.xml
|
|
51
|
+
*.cover
|
|
52
|
+
*.py.cover
|
|
53
|
+
.hypothesis/
|
|
54
|
+
.pytest_cache/
|
|
55
|
+
cover/
|
|
56
|
+
|
|
57
|
+
# Translations
|
|
58
|
+
*.mo
|
|
59
|
+
*.pot
|
|
60
|
+
|
|
61
|
+
# Django stuff:
|
|
62
|
+
*.log
|
|
63
|
+
local_settings.py
|
|
64
|
+
db.sqlite3
|
|
65
|
+
db.sqlite3-journal
|
|
66
|
+
|
|
67
|
+
# Flask stuff:
|
|
68
|
+
instance/
|
|
69
|
+
.webassets-cache
|
|
70
|
+
|
|
71
|
+
# Scrapy stuff:
|
|
72
|
+
.scrapy
|
|
73
|
+
|
|
74
|
+
# Sphinx documentation
|
|
75
|
+
docs/_build/
|
|
76
|
+
|
|
77
|
+
# PyBuilder
|
|
78
|
+
.pybuilder/
|
|
79
|
+
target/
|
|
80
|
+
|
|
81
|
+
# Jupyter Notebook
|
|
82
|
+
.ipynb_checkpoints
|
|
83
|
+
|
|
84
|
+
# IPython
|
|
85
|
+
profile_default/
|
|
86
|
+
ipython_config.py
|
|
87
|
+
|
|
88
|
+
# pyenv
|
|
89
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
90
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
91
|
+
# .python-version
|
|
92
|
+
|
|
93
|
+
# pipenv
|
|
94
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
95
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
96
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
97
|
+
# install all needed dependencies.
|
|
98
|
+
#Pipfile.lock
|
|
99
|
+
|
|
100
|
+
# UV
|
|
101
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
102
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
103
|
+
# commonly ignored for libraries.
|
|
104
|
+
#uv.lock
|
|
105
|
+
|
|
106
|
+
# poetry
|
|
107
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
108
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
109
|
+
# commonly ignored for libraries.
|
|
110
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
111
|
+
#poetry.lock
|
|
112
|
+
#poetry.toml
|
|
113
|
+
|
|
114
|
+
# pdm
|
|
115
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
116
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
117
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
118
|
+
#pdm.lock
|
|
119
|
+
#pdm.toml
|
|
120
|
+
.pdm-python
|
|
121
|
+
.pdm-build/
|
|
122
|
+
|
|
123
|
+
# pixi
|
|
124
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
125
|
+
#pixi.lock
|
|
126
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
127
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
128
|
+
.pixi
|
|
129
|
+
|
|
130
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
131
|
+
__pypackages__/
|
|
132
|
+
|
|
133
|
+
# Celery stuff
|
|
134
|
+
celerybeat-schedule
|
|
135
|
+
celerybeat.pid
|
|
136
|
+
|
|
137
|
+
# SageMath parsed files
|
|
138
|
+
*.sage.py
|
|
139
|
+
|
|
140
|
+
# Environments
|
|
141
|
+
.env
|
|
142
|
+
.envrc
|
|
143
|
+
.venv
|
|
144
|
+
env/
|
|
145
|
+
venv/
|
|
146
|
+
ENV/
|
|
147
|
+
env.bak/
|
|
148
|
+
venv.bak/
|
|
149
|
+
|
|
150
|
+
# Spyder project settings
|
|
151
|
+
.spyderproject
|
|
152
|
+
.spyproject
|
|
153
|
+
|
|
154
|
+
# Rope project settings
|
|
155
|
+
.ropeproject
|
|
156
|
+
|
|
157
|
+
# mkdocs documentation
|
|
158
|
+
/site
|
|
159
|
+
|
|
160
|
+
# mypy
|
|
161
|
+
.mypy_cache/
|
|
162
|
+
.dmypy.json
|
|
163
|
+
dmypy.json
|
|
164
|
+
|
|
165
|
+
# Pyre type checker
|
|
166
|
+
.pyre/
|
|
167
|
+
|
|
168
|
+
# pytype static type analyzer
|
|
169
|
+
.pytype/
|
|
170
|
+
|
|
171
|
+
# Cython debug symbols
|
|
172
|
+
cython_debug/
|
|
173
|
+
|
|
174
|
+
# PyCharm
|
|
175
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
176
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
177
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
178
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
179
|
+
#.idea/
|
|
180
|
+
|
|
181
|
+
# Abstra
|
|
182
|
+
# Abstra is an AI-powered process automation framework.
|
|
183
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
184
|
+
# Learn more at https://abstra.io/docs
|
|
185
|
+
.abstra/
|
|
186
|
+
|
|
187
|
+
# Visual Studio Code
|
|
188
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
189
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
190
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
191
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
192
|
+
# .vscode/
|
|
193
|
+
|
|
194
|
+
# Ruff stuff:
|
|
195
|
+
.ruff_cache/
|
|
196
|
+
|
|
197
|
+
# PyPI configuration file
|
|
198
|
+
.pypirc
|
|
199
|
+
|
|
200
|
+
# Cursor
|
|
201
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
202
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
203
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
204
|
+
.cursorignore
|
|
205
|
+
.cursorindexingignore
|
|
206
|
+
|
|
207
|
+
# Marimo
|
|
208
|
+
marimo/_static/
|
|
209
|
+
marimo/_lsp/
|
|
210
|
+
__marimo__/
|
|
211
|
+
|
|
212
|
+
# Claude Code local state
|
|
213
|
+
.claude/
|
|
214
|
+
|
|
215
|
+
# prawduct session evidence (local governance artifacts, never shipped)
|
|
216
|
+
.prawduct/
|
|
217
|
+
|
|
218
|
+
# macOS folder metadata
|
|
219
|
+
.DS_Store
|
|
220
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Pace
|
|
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,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 3tears-iam
|
|
3
|
+
Version: 0.20.0
|
|
4
|
+
Summary: Identity and access primitives: OAuth2/OIDC, SAML, passwords, JWT sessions, DPoP, TOTP, WebAuthn, and the anti-automation controls that guard them
|
|
5
|
+
Project-URL: Repository, https://github.com/pacepace/3tears
|
|
6
|
+
Author: pace
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Requires-Dist: 3tears-agent-acl<0.21.0,>=0.20.0
|
|
19
|
+
Requires-Dist: 3tears-nats[client]<0.21.0,>=0.20.0
|
|
20
|
+
Requires-Dist: 3tears-observe<0.21.0,>=0.20.0
|
|
21
|
+
Requires-Dist: 3tears<0.21.0,>=0.20.0
|
|
22
|
+
Requires-Dist: argon2-cffi>=25.1.0
|
|
23
|
+
Requires-Dist: bcrypt>=5.0.0
|
|
24
|
+
Requires-Dist: cryptography>=43.0
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: joserfc>=1.0
|
|
27
|
+
Requires-Dist: pydantic>=2
|
|
28
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
29
|
+
Requires-Dist: pyotp>=2.9
|
|
30
|
+
Provides-Extra: saml
|
|
31
|
+
Requires-Dist: defusedxml>=0.7; extra == 'saml'
|
|
32
|
+
Requires-Dist: pysaml2>=7.5.0; extra == 'saml'
|
|
33
|
+
Provides-Extra: webauthn
|
|
34
|
+
Requires-Dist: webauthn>=3.0; extra == 'webauthn'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# 3tears-iam
|
|
38
|
+
|
|
39
|
+
`threetears.iam` -- the identity and access primitives every authenticating
|
|
40
|
+
service in the platform needs: password handling, OAuth2/OIDC, SAML, GitHub
|
|
41
|
+
sign-in, session tokens, DPoP, TOTP, WebAuthn, and the anti-automation
|
|
42
|
+
controls that keep all of it from being brute-forced.
|
|
43
|
+
|
|
44
|
+
## Why this exists
|
|
45
|
+
|
|
46
|
+
Two services in this ecosystem grew their own identity layers independently.
|
|
47
|
+
Both wrote argon2id password hashing with anti-enumeration timing. Both wrote
|
|
48
|
+
a GitHub OAuth2 authorization-code flow. Both wrote a NATS-KV login throttle,
|
|
49
|
+
a single-use SHA-256 ticket store, and a JWT mint/verify pair that pins its
|
|
50
|
+
claim set. Neither could use the other's, because each was welded to its own
|
|
51
|
+
database schema, its own transport, and its own config prefix.
|
|
52
|
+
|
|
53
|
+
That is the failure this package exists to stop. The protocol work -- RFC 7636
|
|
54
|
+
PKCE, RFC 9449 DPoP, RFC 6238 TOTP, OIDC discovery and `id_token` verification,
|
|
55
|
+
SAML assertion handling, the OAuth2 code exchange -- is the same everywhere.
|
|
56
|
+
Getting it subtly wrong is a security bug, and getting it subtly wrong twice
|
|
57
|
+
means fixing it twice, in two repos, on two schedules, and finding out the
|
|
58
|
+
second one was missed during an incident.
|
|
59
|
+
|
|
60
|
+
## Model
|
|
61
|
+
|
|
62
|
+
The package owns **protocol, crypto, and policy**. It owns nobody's database
|
|
63
|
+
schema and nobody's wire DTOs.
|
|
64
|
+
|
|
65
|
+
That line is deliberate. The two services that seeded this package disagree on
|
|
66
|
+
almost everything below the protocol layer -- one is NATS-RPC-native with a
|
|
67
|
+
multi-tenant Postgres `identity` schema, the other is a FastAPI app with its own
|
|
68
|
+
control plane -- and any attempt to unify their persistence would have produced
|
|
69
|
+
an abstraction neither could use. So state lives behind narrow Protocols
|
|
70
|
+
(`SingleUseTicketStore`, `AttemptLimiter`, `StateStore`), with a NATS-KV
|
|
71
|
+
implementation shipped for the common case and nothing stopping a caller from
|
|
72
|
+
supplying its own.
|
|
73
|
+
|
|
74
|
+
Everything else follows from that:
|
|
75
|
+
|
|
76
|
+
- **Pure functions where the protocol allows it.** PKCE verification, password
|
|
77
|
+
policy, step-up freshness, claim mapping, and API-key hashing take arguments
|
|
78
|
+
and return answers. No I/O, no clock you cannot inject, no global state.
|
|
79
|
+
- **Algorithms are pinned from literals, never read from the input.** A DPoP
|
|
80
|
+
proof does not get to say which algorithm verifies it. An `id_token` does not
|
|
81
|
+
get to select `none`. This mirrors `threetears.core.security.identity_token`'s
|
|
82
|
+
discipline, and the pins are written so a static reader can audit them.
|
|
83
|
+
- **Fail closed by default, and without a side channel.** A malformed stored hash is an
|
|
84
|
+
authentication failure, not a 500. The one place a caller may choose otherwise is
|
|
85
|
+
`NatsKvAttemptLimiter`'s `fail_open`, which exists for a cheap throttle sitting in front
|
|
86
|
+
of an authoritative check -- it defaults to closed, and a counter with nothing behind it
|
|
87
|
+
must leave it that way. A rejected password never says *which*
|
|
88
|
+
rule it broke when saying so would build an oracle. Errors carry structural
|
|
89
|
+
reasons only -- never token strings, key material, or credentials -- so they
|
|
90
|
+
are safe to log at a verification boundary.
|
|
91
|
+
- **Builds on core, does not fork it.** `jwk_thumbprint`, `build_jwks`,
|
|
92
|
+
`generate_signing_keypair`, `ReplayGuard`, `RevocationGuard`, `WindowedCounter`
|
|
93
|
+
and `seal`/`open_secret` already exist in `threetears.core`. This package
|
|
94
|
+
imports them.
|
|
95
|
+
|
|
96
|
+
## Public surface
|
|
97
|
+
|
|
98
|
+
Imported per module -- `threetears.iam` itself exports only `__version__`, so reach for the
|
|
99
|
+
submodule that owns the thing:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from threetears.iam.passwords import hash_password
|
|
103
|
+
from threetears.iam.tokens import SessionClaims, mint_session_token
|
|
104
|
+
from threetears.iam.stores.nats_kv import state_store, ticket_store
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **Passwords** (`.passwords`, `.breach`) -- `hash_password`, `verify_password`, `validate_new_password`,
|
|
108
|
+
`normalize_password`, `PasswordVerifyResult`, `PasswordPolicyError`, plus
|
|
109
|
+
`BreachCorpus` for k-anonymity breach screening. argon2id for new hashes,
|
|
110
|
+
bcrypt verify-then-upgrade for migrated ones, NFKC normalization always.
|
|
111
|
+
- **OAuth2 / OIDC** (`.pkce`, `.oidc`, `.github`) -- `PkceChallenge` and the RFC 7636 verifier, `OidcDiscoveryClient`,
|
|
112
|
+
`verify_id_token`, `OidcIdentity`, `GithubOAuth2Client`, `GithubProfile`.
|
|
113
|
+
- **SAML** (`.saml`, extra: `saml`) -- `SamlMetadataResolver`, assertion identity
|
|
114
|
+
extraction, relay-state validation.
|
|
115
|
+
- **Sessions** (`.tokens`, `.rotation`) -- `SessionClaims`, `mint_session_token`,
|
|
116
|
+
`verify_session_token` over EdDSA or HS256, `mint_token_pair`, `TokenPair`,
|
|
117
|
+
`sole_audience`, and `rotate_refresh_token` with reuse detection.
|
|
118
|
+
- **Proof of possession** (`.dpop`) -- `validate_dpop_proof` (RFC 9449, ES256/P-256).
|
|
119
|
+
- **Second factors** (`.totp`, `.webauthn`) -- TOTP enrolment and verification, backup codes, and
|
|
120
|
+
(extra: `webauthn`) passkey registration/assertion helpers.
|
|
121
|
+
- **Anti-automation** (`.stores`, `.clientip`) -- the `AttemptLimiter` Protocol and its
|
|
122
|
+
`NatsKvAttemptLimiter` implementation over `threetears.core.coordination.WindowedCounter`,
|
|
123
|
+
plus `resolve_client_ip` for trusted-proxy-aware rate-limit keying.
|
|
124
|
+
- **Storage seams** (`.stores`) -- `SingleUseTicketStore` and `StateStore` Protocols,
|
|
125
|
+
`hash_ticket`/`new_ticket_secret`, the `threetears.iam.stores.nats_kv` implementations with
|
|
126
|
+
their `state_store`/`ticket_store` factories, and in-memory doubles in
|
|
127
|
+
`threetears.iam.stores.memory` for consumer tests.
|
|
128
|
+
|
|
129
|
+
## Install
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install 3tears-iam
|
|
133
|
+
pip install '3tears-iam[saml]' # adds pysaml2; needs the xmlsec1 system binary
|
|
134
|
+
pip install '3tears-iam[webauthn]' # adds passkey support
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Versioning policy
|
|
138
|
+
|
|
139
|
+
`3tears-iam` versions in lockstep with the rest of the 3tears monorepo: every
|
|
140
|
+
package shares one version, tracking the framework git tag. All packages move
|
|
141
|
+
together.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# 3tears-iam
|
|
2
|
+
|
|
3
|
+
`threetears.iam` -- the identity and access primitives every authenticating
|
|
4
|
+
service in the platform needs: password handling, OAuth2/OIDC, SAML, GitHub
|
|
5
|
+
sign-in, session tokens, DPoP, TOTP, WebAuthn, and the anti-automation
|
|
6
|
+
controls that keep all of it from being brute-forced.
|
|
7
|
+
|
|
8
|
+
## Why this exists
|
|
9
|
+
|
|
10
|
+
Two services in this ecosystem grew their own identity layers independently.
|
|
11
|
+
Both wrote argon2id password hashing with anti-enumeration timing. Both wrote
|
|
12
|
+
a GitHub OAuth2 authorization-code flow. Both wrote a NATS-KV login throttle,
|
|
13
|
+
a single-use SHA-256 ticket store, and a JWT mint/verify pair that pins its
|
|
14
|
+
claim set. Neither could use the other's, because each was welded to its own
|
|
15
|
+
database schema, its own transport, and its own config prefix.
|
|
16
|
+
|
|
17
|
+
That is the failure this package exists to stop. The protocol work -- RFC 7636
|
|
18
|
+
PKCE, RFC 9449 DPoP, RFC 6238 TOTP, OIDC discovery and `id_token` verification,
|
|
19
|
+
SAML assertion handling, the OAuth2 code exchange -- is the same everywhere.
|
|
20
|
+
Getting it subtly wrong is a security bug, and getting it subtly wrong twice
|
|
21
|
+
means fixing it twice, in two repos, on two schedules, and finding out the
|
|
22
|
+
second one was missed during an incident.
|
|
23
|
+
|
|
24
|
+
## Model
|
|
25
|
+
|
|
26
|
+
The package owns **protocol, crypto, and policy**. It owns nobody's database
|
|
27
|
+
schema and nobody's wire DTOs.
|
|
28
|
+
|
|
29
|
+
That line is deliberate. The two services that seeded this package disagree on
|
|
30
|
+
almost everything below the protocol layer -- one is NATS-RPC-native with a
|
|
31
|
+
multi-tenant Postgres `identity` schema, the other is a FastAPI app with its own
|
|
32
|
+
control plane -- and any attempt to unify their persistence would have produced
|
|
33
|
+
an abstraction neither could use. So state lives behind narrow Protocols
|
|
34
|
+
(`SingleUseTicketStore`, `AttemptLimiter`, `StateStore`), with a NATS-KV
|
|
35
|
+
implementation shipped for the common case and nothing stopping a caller from
|
|
36
|
+
supplying its own.
|
|
37
|
+
|
|
38
|
+
Everything else follows from that:
|
|
39
|
+
|
|
40
|
+
- **Pure functions where the protocol allows it.** PKCE verification, password
|
|
41
|
+
policy, step-up freshness, claim mapping, and API-key hashing take arguments
|
|
42
|
+
and return answers. No I/O, no clock you cannot inject, no global state.
|
|
43
|
+
- **Algorithms are pinned from literals, never read from the input.** A DPoP
|
|
44
|
+
proof does not get to say which algorithm verifies it. An `id_token` does not
|
|
45
|
+
get to select `none`. This mirrors `threetears.core.security.identity_token`'s
|
|
46
|
+
discipline, and the pins are written so a static reader can audit them.
|
|
47
|
+
- **Fail closed by default, and without a side channel.** A malformed stored hash is an
|
|
48
|
+
authentication failure, not a 500. The one place a caller may choose otherwise is
|
|
49
|
+
`NatsKvAttemptLimiter`'s `fail_open`, which exists for a cheap throttle sitting in front
|
|
50
|
+
of an authoritative check -- it defaults to closed, and a counter with nothing behind it
|
|
51
|
+
must leave it that way. A rejected password never says *which*
|
|
52
|
+
rule it broke when saying so would build an oracle. Errors carry structural
|
|
53
|
+
reasons only -- never token strings, key material, or credentials -- so they
|
|
54
|
+
are safe to log at a verification boundary.
|
|
55
|
+
- **Builds on core, does not fork it.** `jwk_thumbprint`, `build_jwks`,
|
|
56
|
+
`generate_signing_keypair`, `ReplayGuard`, `RevocationGuard`, `WindowedCounter`
|
|
57
|
+
and `seal`/`open_secret` already exist in `threetears.core`. This package
|
|
58
|
+
imports them.
|
|
59
|
+
|
|
60
|
+
## Public surface
|
|
61
|
+
|
|
62
|
+
Imported per module -- `threetears.iam` itself exports only `__version__`, so reach for the
|
|
63
|
+
submodule that owns the thing:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from threetears.iam.passwords import hash_password
|
|
67
|
+
from threetears.iam.tokens import SessionClaims, mint_session_token
|
|
68
|
+
from threetears.iam.stores.nats_kv import state_store, ticket_store
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **Passwords** (`.passwords`, `.breach`) -- `hash_password`, `verify_password`, `validate_new_password`,
|
|
72
|
+
`normalize_password`, `PasswordVerifyResult`, `PasswordPolicyError`, plus
|
|
73
|
+
`BreachCorpus` for k-anonymity breach screening. argon2id for new hashes,
|
|
74
|
+
bcrypt verify-then-upgrade for migrated ones, NFKC normalization always.
|
|
75
|
+
- **OAuth2 / OIDC** (`.pkce`, `.oidc`, `.github`) -- `PkceChallenge` and the RFC 7636 verifier, `OidcDiscoveryClient`,
|
|
76
|
+
`verify_id_token`, `OidcIdentity`, `GithubOAuth2Client`, `GithubProfile`.
|
|
77
|
+
- **SAML** (`.saml`, extra: `saml`) -- `SamlMetadataResolver`, assertion identity
|
|
78
|
+
extraction, relay-state validation.
|
|
79
|
+
- **Sessions** (`.tokens`, `.rotation`) -- `SessionClaims`, `mint_session_token`,
|
|
80
|
+
`verify_session_token` over EdDSA or HS256, `mint_token_pair`, `TokenPair`,
|
|
81
|
+
`sole_audience`, and `rotate_refresh_token` with reuse detection.
|
|
82
|
+
- **Proof of possession** (`.dpop`) -- `validate_dpop_proof` (RFC 9449, ES256/P-256).
|
|
83
|
+
- **Second factors** (`.totp`, `.webauthn`) -- TOTP enrolment and verification, backup codes, and
|
|
84
|
+
(extra: `webauthn`) passkey registration/assertion helpers.
|
|
85
|
+
- **Anti-automation** (`.stores`, `.clientip`) -- the `AttemptLimiter` Protocol and its
|
|
86
|
+
`NatsKvAttemptLimiter` implementation over `threetears.core.coordination.WindowedCounter`,
|
|
87
|
+
plus `resolve_client_ip` for trusted-proxy-aware rate-limit keying.
|
|
88
|
+
- **Storage seams** (`.stores`) -- `SingleUseTicketStore` and `StateStore` Protocols,
|
|
89
|
+
`hash_ticket`/`new_ticket_secret`, the `threetears.iam.stores.nats_kv` implementations with
|
|
90
|
+
their `state_store`/`ticket_store` factories, and in-memory doubles in
|
|
91
|
+
`threetears.iam.stores.memory` for consumer tests.
|
|
92
|
+
|
|
93
|
+
## Install
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pip install 3tears-iam
|
|
97
|
+
pip install '3tears-iam[saml]' # adds pysaml2; needs the xmlsec1 system binary
|
|
98
|
+
pip install '3tears-iam[webauthn]' # adds passkey support
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Versioning policy
|
|
102
|
+
|
|
103
|
+
`3tears-iam` versions in lockstep with the rest of the 3tears monorepo: every
|
|
104
|
+
package shares one version, tracking the framework git tag. All packages move
|
|
105
|
+
together.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "3tears-iam"
|
|
7
|
+
version = "0.20.0"
|
|
8
|
+
description = "Identity and access primitives: OAuth2/OIDC, SAML, passwords, JWT sessions, DPoP, TOTP, WebAuthn, and the anti-automation controls that guard them"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
authors = [{name = "pace"}]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# core supplies the crypto this package builds ON rather than beside:
|
|
26
|
+
# jwk_thumbprint/build_jwks/generate_signing_keypair (identity_token),
|
|
27
|
+
# ReplayGuard/RevocationGuard/WindowedCounter (coordination), and
|
|
28
|
+
# seal/open_secret (encryption) for TOTP seed material at rest. Forking
|
|
29
|
+
# any of those would give the platform two answers to the same question.
|
|
30
|
+
"3tears>=0.20.0,<0.21.0",
|
|
31
|
+
"3tears-observe>=0.20.0,<0.21.0",
|
|
32
|
+
# the sensitive-action taxonomy. agent-acl's ImpersonationCategory and
|
|
33
|
+
# identity-core's SensitiveActionCategory declared the same six strings
|
|
34
|
+
# independently, each documenting that the copy existed only because no
|
|
35
|
+
# import path connected them. This package is that path, so the enum is
|
|
36
|
+
# imported here and the second declaration goes away.
|
|
37
|
+
"3tears-agent-acl>=0.20.0,<0.21.0",
|
|
38
|
+
# the KV adapters in threetears.iam.stores.nats_kv. Every state this
|
|
39
|
+
# package keeps (auth codes, OAuth state, single-use tickets, attempt
|
|
40
|
+
# counters) is short-lived and TTL'd, which is a KV bucket's job, not a
|
|
41
|
+
# table's -- and both first consumers already run NATS.
|
|
42
|
+
"3tears-nats[client]>=0.20.0,<0.21.0",
|
|
43
|
+
# argon2id for password hashing, bcrypt for verify-then-upgrade of hashes
|
|
44
|
+
# migrated in from an older system. bcrypt is verify-only here: nothing in
|
|
45
|
+
# this package ever WRITES a bcrypt hash.
|
|
46
|
+
"argon2-cffi>=25.1.0",
|
|
47
|
+
"bcrypt>=5.0.0",
|
|
48
|
+
# session tokens (EdDSA or HS256), DPoP proofs, and OIDC id_token
|
|
49
|
+
# verification. [crypto] pulls the asymmetric backends.
|
|
50
|
+
"pyjwt[crypto]>=2.8",
|
|
51
|
+
# Ed25519/EC key handling, and AES-256-GCM for TOTP seeds at rest.
|
|
52
|
+
"cryptography>=43.0",
|
|
53
|
+
# OIDC discovery, the OAuth2 token exchange, provider userinfo/profile
|
|
54
|
+
# fetches, and the k-anonymity breach-corpus range query. Used directly
|
|
55
|
+
# rather than through authlib: the flows here are small enough that a
|
|
56
|
+
# second OAuth framework would be more surface than saved code.
|
|
57
|
+
"httpx>=0.27",
|
|
58
|
+
# TOTP code generation/verification (RFC 6238).
|
|
59
|
+
"pyotp>=2.9",
|
|
60
|
+
# OIDC id_token verification. joserfc rather than pyjwt for this one path: it models a
|
|
61
|
+
# JWKS KeySet and a claims registry directly, which is what an id_token needs, and
|
|
62
|
+
# authlib's own jose module -- the obvious alternative -- is deprecated.
|
|
63
|
+
"joserfc>=1.0",
|
|
64
|
+
# SecretStr on the seed-sealing surface, and the type core's seal/open_secret speak.
|
|
65
|
+
# Declared directly rather than leaned on transitively: this package names it in a
|
|
66
|
+
# public signature, so it is a real dependency of this API, not an implementation detail.
|
|
67
|
+
"pydantic>=2",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[project.optional-dependencies]
|
|
71
|
+
# SAML drags pysaml2 AND the xmlsec1 SYSTEM binary in with it. A consumer that
|
|
72
|
+
# only needs OAuth/OIDC should not inherit an apt-get line, so the SAML service
|
|
73
|
+
# provider lives behind this extra and threetears.iam.saml raises a pointed
|
|
74
|
+
# ImportError when it is missing.
|
|
75
|
+
saml = [
|
|
76
|
+
"pysaml2>=7.5.0",
|
|
77
|
+
"defusedxml>=0.7",
|
|
78
|
+
]
|
|
79
|
+
# WebAuthn/passkey registration + assertion verification. Optional for the same
|
|
80
|
+
# reason: a consumer doing password + OIDC only should not carry it.
|
|
81
|
+
webauthn = [
|
|
82
|
+
"webauthn>=3.0",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[project.urls]
|
|
86
|
+
Repository = "https://github.com/pacepace/3tears"
|
|
87
|
+
|
|
88
|
+
[tool.hatch.build.targets.wheel]
|
|
89
|
+
packages = ["src/threetears"]
|
|
90
|
+
|
|
91
|
+
[tool.uv.sources]
|
|
92
|
+
3tears = { workspace = true }
|
|
93
|
+
3tears-observe = { workspace = true }
|
|
94
|
+
3tears-agent-acl = { workspace = true }
|
|
95
|
+
3tears-nats = { workspace = true }
|
|
96
|
+
|
|
97
|
+
[tool.mypy]
|
|
98
|
+
strict = true
|
|
99
|
+
mypy_path = "src"
|
|
100
|
+
packages = ["threetears.iam"]
|
|
101
|
+
explicit_package_bases = true
|
|
102
|
+
ignore_missing_imports = true
|
|
103
|
+
|
|
104
|
+
# no [tool.pytest.ini_options] block: pytest's rootdir detection picks the
|
|
105
|
+
# closest pyproject with that block, so a per-package one would hide the
|
|
106
|
+
# workspace conftest.py and unregister the canonical pytest_plugins line.
|
|
107
|
+
# inherit from the workspace instead.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""3tears-iam: identity and access primitives.
|
|
2
|
+
|
|
3
|
+
Protocol, crypto, and policy for authenticating callers -- passwords, OAuth2/
|
|
4
|
+
OIDC, SAML, session tokens, DPoP, TOTP, WebAuthn, and the anti-automation
|
|
5
|
+
controls that guard them.
|
|
6
|
+
|
|
7
|
+
This package owns no database schema and no wire DTOs. State lives behind the
|
|
8
|
+
Protocols in :mod:`threetears.iam.stores`, with a NATS-KV implementation
|
|
9
|
+
supplied for the common case.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
13
|
+
from importlib.metadata import version as _version
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = _version("3tears-iam")
|
|
17
|
+
except _PackageNotFoundError: # pragma: no cover - dev fallback
|
|
18
|
+
__version__ = "unknown"
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The one place this package turns a high-entropy secret into its stored form.
|
|
2
|
+
|
|
3
|
+
Two public functions need it -- :func:`threetears.iam.stores.base.hash_ticket` and
|
|
4
|
+
:func:`threetears.iam.apikeys.hash_api_key_secret` -- and they had the same body written
|
|
5
|
+
out twice. They keep separate public names because they are separate contracts with
|
|
6
|
+
separate call sites, and because a future decision to pepper one is a decision about that
|
|
7
|
+
one; what they share is the digest, and sharing it means a change to how this package
|
|
8
|
+
hashes cannot land in one of them and miss the other.
|
|
9
|
+
|
|
10
|
+
**SHA-256, not a password KDF, and that is deliberate for both.** Every value passed here
|
|
11
|
+
is 256 bits of generated randomness, so there is no dictionary for an attacker to run and
|
|
12
|
+
nothing for a slow KDF to slow down. What the stores need instead is an equality lookup,
|
|
13
|
+
which a per-candidate KDF run would make impossible. A user-chosen password is the opposite
|
|
14
|
+
case in every respect and goes through :mod:`threetears.iam.passwords`, which uses
|
|
15
|
+
argon2id. Nothing in this module is appropriate for one.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
|
|
22
|
+
__all__ = ["sha256_hex"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_hex(secret: str) -> str:
|
|
26
|
+
"""SHA-256 hex digest of ``secret``'s UTF-8 bytes.
|
|
27
|
+
|
|
28
|
+
:param secret: the raw, high-entropy secret.
|
|
29
|
+
:ptype secret: str
|
|
30
|
+
:return: the lowercase hex digest.
|
|
31
|
+
:rtype: str
|
|
32
|
+
"""
|
|
33
|
+
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|