obelisk-auth 1.0.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.
- obelisk_auth-1.0.0/.gitignore +56 -0
- obelisk_auth-1.0.0/LICENSE +83 -0
- obelisk_auth-1.0.0/NOTICE +17 -0
- obelisk_auth-1.0.0/PKG-INFO +170 -0
- obelisk_auth-1.0.0/README.md +149 -0
- obelisk_auth-1.0.0/obelisk_auth/__init__.py +455 -0
- obelisk_auth-1.0.0/pyproject.toml +46 -0
- obelisk_auth-1.0.0/tests/test_obelisk_auth.py +394 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
coverage/
|
|
3
|
+
dist/
|
|
4
|
+
.cache/
|
|
5
|
+
# W270 — Playwright capture output. The CANON-053 evidence record is the
|
|
6
|
+
# hash-bound docs/visual-qa/LATEST.json; the PNGs themselves are regenerable
|
|
7
|
+
# local artifacts (14MB+ of binary churn per visual wave does not belong in git).
|
|
8
|
+
output/
|
|
9
|
+
.env
|
|
10
|
+
.env.*
|
|
11
|
+
!.env.example
|
|
12
|
+
*.log
|
|
13
|
+
audits/mcp-keys.json
|
|
14
|
+
audits/*.key
|
|
15
|
+
audits/*.pem
|
|
16
|
+
audits/grants-secret.key
|
|
17
|
+
audits/oidc-signing-key.pem
|
|
18
|
+
# S321 — the post-quantum signing key. `audits/*.pem` already covered the ES256
|
|
19
|
+
# key, but the ML-DSA key persists as an RFC 9964 AKP *JWK* whose `priv` member is
|
|
20
|
+
# the 32-byte seed, so it is a PRIVATE KEY with a .jwk extension that no existing
|
|
21
|
+
# pattern matched. Glob, not just the literal, so a rotation sibling
|
|
22
|
+
# (pq-signing-key.prev.jwk / .next.jwk) cannot be committed either.
|
|
23
|
+
audits/pq-signing-key*.jwk
|
|
24
|
+
audits/*.lock
|
|
25
|
+
context/.session-lock
|
|
26
|
+
STUDIO_AI_MODEL_v2_mobile_frontier_research.docx
|
|
27
|
+
STUDIO_AI_MODEL_v2_mobile_frontier_research.html
|
|
28
|
+
.ops-cache/
|
|
29
|
+
secrets/
|
|
30
|
+
|
|
31
|
+
# stale root session-lock (canonical lock is context/.session-lock)
|
|
32
|
+
.session-lock
|
|
33
|
+
|
|
34
|
+
# Python
|
|
35
|
+
__pycache__/
|
|
36
|
+
*.pyc
|
|
37
|
+
|
|
38
|
+
.secrets/
|
|
39
|
+
|
|
40
|
+
# W141 deploy artifact — stamped by gate-deploy.mjs per deploy, never source
|
|
41
|
+
build-info.json
|
|
42
|
+
|
|
43
|
+
# W214 — ledger reconciliation backup written by scripts/dedupe-cache-ledger.mjs
|
|
44
|
+
# (git history is the real backup; the .bak is a local safety net, never source)
|
|
45
|
+
*.ndjson.bak
|
|
46
|
+
|
|
47
|
+
# Atomic-write temp files (never commit interrupted .tmp debris — W230)
|
|
48
|
+
audits/.*.tmp
|
|
49
|
+
*.tmp
|
|
50
|
+
|
|
51
|
+
# W239 — propagation quarantine. The guard copies every landed blob here before
|
|
52
|
+
# removing it, so a rejected propagation is always recoverable. Local forensic
|
|
53
|
+
# state, not repo content: committing it would add the very clobber the guard
|
|
54
|
+
# just rejected. audits/propagation-log.jsonl is the committed, append-only
|
|
55
|
+
# record of what happened.
|
|
56
|
+
.quarantine/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Obelisk Client SDK License Agreement
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VaultSpark Studios LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This is a proprietary software license, not an open-source license. By
|
|
6
|
+
installing, copying, or otherwise using the software package
|
|
7
|
+
"@vaultspark/obelisk-auth" and its contents (the "SDK"), you ("Licensee")
|
|
8
|
+
agree to be bound by this Agreement. If you do not agree, do not install or
|
|
9
|
+
use the SDK.
|
|
10
|
+
|
|
11
|
+
1. DEFINITIONS.
|
|
12
|
+
"Licensor" means VaultSpark Studios LLC.
|
|
13
|
+
"Obelisk Gate Service" means the hosted identity and authentication service
|
|
14
|
+
operated by Licensor at obeliskgate.com (and its official successors).
|
|
15
|
+
"Application" means Licensee's own software product or service that
|
|
16
|
+
integrates with the Obelisk Gate Service by means of the SDK.
|
|
17
|
+
|
|
18
|
+
2. LICENSE GRANT. Subject to Licensee's continuous compliance with this
|
|
19
|
+
Agreement, Licensor grants Licensee a limited, non-exclusive,
|
|
20
|
+
non-transferable, non-sublicensable, revocable license to install and use
|
|
21
|
+
the SDK in unmodified form, solely to integrate Licensee's Application with
|
|
22
|
+
the official Obelisk Gate Service. No other rights are granted.
|
|
23
|
+
|
|
24
|
+
3. RESTRICTIONS. Except to the extent this Section is unenforceable under
|
|
25
|
+
applicable law, Licensee shall NOT, and shall not permit any third party to:
|
|
26
|
+
(a) fork, copy (except a single installation copy as strictly necessary to
|
|
27
|
+
use the SDK as permitted), or create derivative works of the SDK;
|
|
28
|
+
(b) modify, adapt, translate, or alter the SDK, other than supplying the
|
|
29
|
+
configuration values documented in the README;
|
|
30
|
+
(c) distribute, publish, sublicense, sell, rent, lease, host, or otherwise
|
|
31
|
+
make the SDK (or any part of it, in source or object form) available to
|
|
32
|
+
any third party, except as unmodified installation copies bundled inside
|
|
33
|
+
Licensee's own Application solely to call the Obelisk Gate Service;
|
|
34
|
+
(d) reverse engineer, decompile, or disassemble the SDK, except to the
|
|
35
|
+
limited extent applicable law expressly permits despite this limitation;
|
|
36
|
+
(e) use the SDK with, or to build, any service other than the official
|
|
37
|
+
Obelisk Gate Service, including any competing or substitute identity,
|
|
38
|
+
authentication, or authorization service;
|
|
39
|
+
(f) remove, obscure, or alter any copyright, trademark, license, or
|
|
40
|
+
attribution notice in or on the SDK; or
|
|
41
|
+
(g) use the names, logos, or trademarks of Licensor except as permitted by
|
|
42
|
+
the TRADEMARKS.md file included with the SDK.
|
|
43
|
+
|
|
44
|
+
4. RESERVATION OF RIGHTS. The SDK is licensed, not sold. Licensor and its
|
|
45
|
+
licensors retain all right, title, and interest in and to the SDK, including
|
|
46
|
+
all intellectual property rights. All rights not expressly granted are
|
|
47
|
+
reserved. The Obelisk Gate Service itself — including its identity provider,
|
|
48
|
+
the Obelisk Rating engine, the tamper-evident receipt chain, the Obelisk
|
|
49
|
+
Warden risk engine, and all server-side software and infrastructure — is
|
|
50
|
+
proprietary, is NOT included in or licensed by the SDK, and is not covered by
|
|
51
|
+
this Agreement.
|
|
52
|
+
|
|
53
|
+
5. TRADEMARKS. "Obelisk", "Obelisk Gate", "Obelisk Rating", "Obelisk Warden",
|
|
54
|
+
and "VaultSpark" are trademarks of Licensor. This Agreement grants no
|
|
55
|
+
trademark rights except as expressly stated in TRADEMARKS.md.
|
|
56
|
+
|
|
57
|
+
6. TERM AND TERMINATION. This Agreement is effective until terminated. It
|
|
58
|
+
terminates automatically and immediately upon any breach by Licensee. Upon
|
|
59
|
+
termination, Licensee shall cease all use of the SDK and destroy all copies.
|
|
60
|
+
Sections 3 through 10 survive termination.
|
|
61
|
+
|
|
62
|
+
7. USE OF THE SERVICE. Access to and use of the Obelisk Gate Service is governed
|
|
63
|
+
by its own terms of service (the Gate Toll Terms at obeliskgate.com/legal/terms).
|
|
64
|
+
This Agreement covers only the SDK, and confers no right to use the Service.
|
|
65
|
+
|
|
66
|
+
8. DISCLAIMER OF WARRANTY. THE SDK IS PROVIDED "AS IS" AND "AS AVAILABLE",
|
|
67
|
+
WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
|
68
|
+
TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE,
|
|
69
|
+
AND NON-INFRINGEMENT. LICENSEE BEARS THE ENTIRE RISK OF USE.
|
|
70
|
+
|
|
71
|
+
9. LIMITATION OF LIABILITY. TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT
|
|
72
|
+
SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
|
|
73
|
+
CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR
|
|
74
|
+
GOODWILL, ARISING OUT OF OR RELATED TO THE SDK OR THIS AGREEMENT. LICENSOR'S
|
|
75
|
+
TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED USD $100.
|
|
76
|
+
|
|
77
|
+
10. GENERAL. This Agreement is governed by the laws of the State of Delaware,
|
|
78
|
+
USA, without regard to conflict-of-laws rules. If any provision is held
|
|
79
|
+
unenforceable, the remaining provisions remain in effect. This Agreement is
|
|
80
|
+
the entire agreement between the parties regarding the SDK and supersedes
|
|
81
|
+
all prior understandings regarding its subject matter.
|
|
82
|
+
|
|
83
|
+
Contact: https://obeliskgate.com/help
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
@vaultspark/obelisk-auth
|
|
2
|
+
Copyright (c) 2026 VaultSpark Studios LLC. All rights reserved.
|
|
3
|
+
|
|
4
|
+
This is PROPRIETARY software, licensed — not sold — under the Obelisk Client SDK
|
|
5
|
+
License Agreement (see the LICENSE file). It is NOT open source. You may install
|
|
6
|
+
and use it in unmodified form solely to integrate your application with the
|
|
7
|
+
official Obelisk Gate Service. Forking, modification, redistribution, reverse
|
|
8
|
+
engineering, and use with any other service are prohibited. See LICENSE for the
|
|
9
|
+
full terms and TRADEMARKS.md for trademark use.
|
|
10
|
+
|
|
11
|
+
"Obelisk", "Obelisk Gate", "Obelisk Rating", "Obelisk Warden", and "VaultSpark"
|
|
12
|
+
are trademarks of VaultSpark Studios LLC.
|
|
13
|
+
|
|
14
|
+
This package is a CLIENT SDK only. The Obelisk Gate Service — its identity
|
|
15
|
+
provider, the Obelisk Rating engine, the tamper-evident receipt chain, and the
|
|
16
|
+
Obelisk Warden risk engine — is a proprietary hosted service and is not included
|
|
17
|
+
in, disclosed by, or licensed by this package.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: obelisk-auth
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Drop-in OIDC client for the Obelisk Gate identity platform — login, tokens, refresh, userinfo, and local ES256 token verification.
|
|
5
|
+
Project-URL: Homepage, https://obeliskgate.com
|
|
6
|
+
Author: VaultSpark Studios
|
|
7
|
+
License: Proprietary
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: NOTICE
|
|
10
|
+
Keywords: auth,es256,jwt,obelisk,oidc,openid-connect,pkce
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: cryptography>=42.0.0
|
|
17
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# obelisk-auth (Python)
|
|
23
|
+
|
|
24
|
+
A drop-in OIDC client for the **Obelisk Gate** identity platform
|
|
25
|
+
(`https://obeliskgate.com`). It is the Python counterpart of the JavaScript
|
|
26
|
+
`@obeliskgate/obelisk-auth` package and speaks the exact same contract: login +
|
|
27
|
+
tokens + refresh + userinfo + **local** ES256 token verification.
|
|
28
|
+
|
|
29
|
+
Obelisk is a conformant OpenID Connect provider:
|
|
30
|
+
|
|
31
|
+
- `GET /.well-known/openid-configuration` — discovery (RFC 8414)
|
|
32
|
+
- `GET /.well-known/jwks.json` — JWKS (ES256 / P-256 public keys)
|
|
33
|
+
- `GET /auth/authorize` — authorization-code flow, **PKCE S256 only**
|
|
34
|
+
- `POST /auth/token` — exchange `code`+`code_verifier` → `id_token` / `access_token` / `refresh_token`
|
|
35
|
+
- `GET /auth/userinfo` — Bearer-authenticated claims
|
|
36
|
+
|
|
37
|
+
Tokens are ES256 JWTs, so they can be verified **locally** against the cached
|
|
38
|
+
JWKS with no network round-trip per request — the property that makes Obelisk
|
|
39
|
+
useful as an agent-era auth plane.
|
|
40
|
+
|
|
41
|
+
## The agent-era angle
|
|
42
|
+
|
|
43
|
+
Every token is locally verifiable, and the **id_token** carries an `obelisk`
|
|
44
|
+
claim block that proves *how* the principal authenticated:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
"obelisk": {
|
|
48
|
+
"v": "obelisk-claims-v1",
|
|
49
|
+
"assurance": "passkey",
|
|
50
|
+
"anchor": "0a1b2c3d...",
|
|
51
|
+
"rating": { "...": "live issuer security posture" }
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`assurance` distinguishes operator-proof factors (passkey) from weaker ones, and
|
|
56
|
+
`anchor` ties the issuance into Obelisk's tamper-evident receipt chain. Pull it
|
|
57
|
+
out of verified claims with `obelisk_block(claims)`.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install obelisk-auth
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Dependencies are stdlib `urllib` for HTTP plus `PyJWT` and `cryptography` for
|
|
66
|
+
ES256 verification (no hand-rolled crypto).
|
|
67
|
+
|
|
68
|
+
## Login (copy-paste)
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from obelisk_auth import ObeliskAuth, obelisk_block
|
|
72
|
+
|
|
73
|
+
auth = ObeliskAuth(
|
|
74
|
+
issuer="https://obeliskgate.com",
|
|
75
|
+
client_id="rp-statvault",
|
|
76
|
+
redirect_uri="https://statvault.org/auth/callback",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 1) Start the login. Persist code_verifier + state + nonce in the user session,
|
|
80
|
+
# then redirect the browser to the authorization URL.
|
|
81
|
+
login = auth.begin_login(scope="openid profile offline_access")
|
|
82
|
+
session["pkce"] = {
|
|
83
|
+
"code_verifier": login.code_verifier,
|
|
84
|
+
"state": login.state,
|
|
85
|
+
"nonce": login.nonce,
|
|
86
|
+
}
|
|
87
|
+
redirect(login.authorization_url)
|
|
88
|
+
|
|
89
|
+
# 2) On the callback (e.g. /auth/callback?code=...&state=...):
|
|
90
|
+
# First confirm the returned `state` matches what you stashed (CSRF defense).
|
|
91
|
+
assert request.args["state"] == session["pkce"]["state"]
|
|
92
|
+
|
|
93
|
+
result = auth.complete_login(
|
|
94
|
+
code=request.args["code"],
|
|
95
|
+
code_verifier=session["pkce"]["code_verifier"],
|
|
96
|
+
nonce=session["pkce"]["nonce"],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
print(result.claims["sub"]) # the user's stable subject id
|
|
100
|
+
print(obelisk_block(result.claims)) # assurance + anchor + rating
|
|
101
|
+
|
|
102
|
+
access_token = result.tokens["access_token"]
|
|
103
|
+
refresh_token = result.tokens.get("refresh_token") # present with offline_access
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Verify a token locally (no network per request)
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from obelisk_auth import ObeliskAuth, obelisk_block
|
|
110
|
+
|
|
111
|
+
auth = ObeliskAuth(
|
|
112
|
+
issuer="https://obeliskgate.com",
|
|
113
|
+
client_id="rp-statvault",
|
|
114
|
+
redirect_uri="https://statvault.org/auth/callback",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# On a protected route, given a Bearer access token:
|
|
118
|
+
v = auth.verify_access_token(bearer_token)
|
|
119
|
+
if not v.ok:
|
|
120
|
+
raise Unauthorized(v.reason) # e.g. "expired", "issuer-mismatch"
|
|
121
|
+
|
|
122
|
+
print(v.claims["sub"])
|
|
123
|
+
|
|
124
|
+
# Gate capability on how the user proved themselves:
|
|
125
|
+
block = obelisk_block(v.claims)
|
|
126
|
+
if block and block.get("assurance") != "passkey":
|
|
127
|
+
raise Forbidden("this action requires a passkey-proven session")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The first `verify_*` call fetches the JWKS once and caches it by `kid`; later
|
|
131
|
+
calls verify in-process. If a key rotates (unknown `kid`), the SDK refetches the
|
|
132
|
+
JWKS exactly once and retries.
|
|
133
|
+
|
|
134
|
+
## Refresh + userinfo
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
rotated = auth.refresh(refresh_token) # rotating refresh tokens
|
|
138
|
+
new_access = rotated["access_token"]
|
|
139
|
+
|
|
140
|
+
info = auth.get_userinfo(new_access) # Bearer-authenticated userinfo
|
|
141
|
+
print(info["sub"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> Reusing a superseded refresh token revokes the whole token family on the
|
|
145
|
+
> server (OAuth 2.0 BCP reuse detection); be prepared to force re-authentication.
|
|
146
|
+
|
|
147
|
+
## API
|
|
148
|
+
|
|
149
|
+
| Method | Description |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `discover()` | Fetch + cache the discovery document. |
|
|
152
|
+
| `get_jwks(force=False)` | Fetch + cache the JWKS. |
|
|
153
|
+
| `begin_login(scope=...)` → `LoginStart` | PKCE S256 authorize URL + `code_verifier`/`state`/`nonce`. |
|
|
154
|
+
| `complete_login(code, code_verifier, nonce=...)` → `LoginResult` | Exchange + verify id_token. |
|
|
155
|
+
| `verify_id_token(id_token, nonce=...)` | Local ES256 verify (raises on failure). |
|
|
156
|
+
| `verify_access_token(token)` → `VerifyResult` | Local ES256 verify (never raises). |
|
|
157
|
+
| `refresh(refresh_token)` | Rotate a refresh token. |
|
|
158
|
+
| `get_userinfo(access_token)` | Bearer userinfo. |
|
|
159
|
+
| `obelisk_block(claims)` | Extract the verified `obelisk` assurance block. |
|
|
160
|
+
|
|
161
|
+
## Testing
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install -e ".[test]"
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The bundled tests are pure unit tests (no live server): PKCE-S256 challenge
|
|
169
|
+
correctness, authorization-URL construction, full login flow against a mocked
|
|
170
|
+
token endpoint, and round-trip ES256 sign/verify with a generated P-256 key.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# obelisk-auth (Python)
|
|
2
|
+
|
|
3
|
+
A drop-in OIDC client for the **Obelisk Gate** identity platform
|
|
4
|
+
(`https://obeliskgate.com`). It is the Python counterpart of the JavaScript
|
|
5
|
+
`@obeliskgate/obelisk-auth` package and speaks the exact same contract: login +
|
|
6
|
+
tokens + refresh + userinfo + **local** ES256 token verification.
|
|
7
|
+
|
|
8
|
+
Obelisk is a conformant OpenID Connect provider:
|
|
9
|
+
|
|
10
|
+
- `GET /.well-known/openid-configuration` — discovery (RFC 8414)
|
|
11
|
+
- `GET /.well-known/jwks.json` — JWKS (ES256 / P-256 public keys)
|
|
12
|
+
- `GET /auth/authorize` — authorization-code flow, **PKCE S256 only**
|
|
13
|
+
- `POST /auth/token` — exchange `code`+`code_verifier` → `id_token` / `access_token` / `refresh_token`
|
|
14
|
+
- `GET /auth/userinfo` — Bearer-authenticated claims
|
|
15
|
+
|
|
16
|
+
Tokens are ES256 JWTs, so they can be verified **locally** against the cached
|
|
17
|
+
JWKS with no network round-trip per request — the property that makes Obelisk
|
|
18
|
+
useful as an agent-era auth plane.
|
|
19
|
+
|
|
20
|
+
## The agent-era angle
|
|
21
|
+
|
|
22
|
+
Every token is locally verifiable, and the **id_token** carries an `obelisk`
|
|
23
|
+
claim block that proves *how* the principal authenticated:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
"obelisk": {
|
|
27
|
+
"v": "obelisk-claims-v1",
|
|
28
|
+
"assurance": "passkey",
|
|
29
|
+
"anchor": "0a1b2c3d...",
|
|
30
|
+
"rating": { "...": "live issuer security posture" }
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`assurance` distinguishes operator-proof factors (passkey) from weaker ones, and
|
|
35
|
+
`anchor` ties the issuance into Obelisk's tamper-evident receipt chain. Pull it
|
|
36
|
+
out of verified claims with `obelisk_block(claims)`.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install obelisk-auth
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Dependencies are stdlib `urllib` for HTTP plus `PyJWT` and `cryptography` for
|
|
45
|
+
ES256 verification (no hand-rolled crypto).
|
|
46
|
+
|
|
47
|
+
## Login (copy-paste)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from obelisk_auth import ObeliskAuth, obelisk_block
|
|
51
|
+
|
|
52
|
+
auth = ObeliskAuth(
|
|
53
|
+
issuer="https://obeliskgate.com",
|
|
54
|
+
client_id="rp-statvault",
|
|
55
|
+
redirect_uri="https://statvault.org/auth/callback",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 1) Start the login. Persist code_verifier + state + nonce in the user session,
|
|
59
|
+
# then redirect the browser to the authorization URL.
|
|
60
|
+
login = auth.begin_login(scope="openid profile offline_access")
|
|
61
|
+
session["pkce"] = {
|
|
62
|
+
"code_verifier": login.code_verifier,
|
|
63
|
+
"state": login.state,
|
|
64
|
+
"nonce": login.nonce,
|
|
65
|
+
}
|
|
66
|
+
redirect(login.authorization_url)
|
|
67
|
+
|
|
68
|
+
# 2) On the callback (e.g. /auth/callback?code=...&state=...):
|
|
69
|
+
# First confirm the returned `state` matches what you stashed (CSRF defense).
|
|
70
|
+
assert request.args["state"] == session["pkce"]["state"]
|
|
71
|
+
|
|
72
|
+
result = auth.complete_login(
|
|
73
|
+
code=request.args["code"],
|
|
74
|
+
code_verifier=session["pkce"]["code_verifier"],
|
|
75
|
+
nonce=session["pkce"]["nonce"],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
print(result.claims["sub"]) # the user's stable subject id
|
|
79
|
+
print(obelisk_block(result.claims)) # assurance + anchor + rating
|
|
80
|
+
|
|
81
|
+
access_token = result.tokens["access_token"]
|
|
82
|
+
refresh_token = result.tokens.get("refresh_token") # present with offline_access
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Verify a token locally (no network per request)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from obelisk_auth import ObeliskAuth, obelisk_block
|
|
89
|
+
|
|
90
|
+
auth = ObeliskAuth(
|
|
91
|
+
issuer="https://obeliskgate.com",
|
|
92
|
+
client_id="rp-statvault",
|
|
93
|
+
redirect_uri="https://statvault.org/auth/callback",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# On a protected route, given a Bearer access token:
|
|
97
|
+
v = auth.verify_access_token(bearer_token)
|
|
98
|
+
if not v.ok:
|
|
99
|
+
raise Unauthorized(v.reason) # e.g. "expired", "issuer-mismatch"
|
|
100
|
+
|
|
101
|
+
print(v.claims["sub"])
|
|
102
|
+
|
|
103
|
+
# Gate capability on how the user proved themselves:
|
|
104
|
+
block = obelisk_block(v.claims)
|
|
105
|
+
if block and block.get("assurance") != "passkey":
|
|
106
|
+
raise Forbidden("this action requires a passkey-proven session")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The first `verify_*` call fetches the JWKS once and caches it by `kid`; later
|
|
110
|
+
calls verify in-process. If a key rotates (unknown `kid`), the SDK refetches the
|
|
111
|
+
JWKS exactly once and retries.
|
|
112
|
+
|
|
113
|
+
## Refresh + userinfo
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
rotated = auth.refresh(refresh_token) # rotating refresh tokens
|
|
117
|
+
new_access = rotated["access_token"]
|
|
118
|
+
|
|
119
|
+
info = auth.get_userinfo(new_access) # Bearer-authenticated userinfo
|
|
120
|
+
print(info["sub"])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
> Reusing a superseded refresh token revokes the whole token family on the
|
|
124
|
+
> server (OAuth 2.0 BCP reuse detection); be prepared to force re-authentication.
|
|
125
|
+
|
|
126
|
+
## API
|
|
127
|
+
|
|
128
|
+
| Method | Description |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `discover()` | Fetch + cache the discovery document. |
|
|
131
|
+
| `get_jwks(force=False)` | Fetch + cache the JWKS. |
|
|
132
|
+
| `begin_login(scope=...)` → `LoginStart` | PKCE S256 authorize URL + `code_verifier`/`state`/`nonce`. |
|
|
133
|
+
| `complete_login(code, code_verifier, nonce=...)` → `LoginResult` | Exchange + verify id_token. |
|
|
134
|
+
| `verify_id_token(id_token, nonce=...)` | Local ES256 verify (raises on failure). |
|
|
135
|
+
| `verify_access_token(token)` → `VerifyResult` | Local ES256 verify (never raises). |
|
|
136
|
+
| `refresh(refresh_token)` | Rotate a refresh token. |
|
|
137
|
+
| `get_userinfo(access_token)` | Bearer userinfo. |
|
|
138
|
+
| `obelisk_block(claims)` | Extract the verified `obelisk` assurance block. |
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install -e ".[test]"
|
|
144
|
+
pytest
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The bundled tests are pure unit tests (no live server): PKCE-S256 challenge
|
|
148
|
+
correctness, authorization-URL construction, full login flow against a mocked
|
|
149
|
+
token endpoint, and round-trip ES256 sign/verify with a generated P-256 key.
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""obelisk_auth — a drop-in OIDC client for the Obelisk Gate identity platform.
|
|
2
|
+
|
|
3
|
+
Obelisk (https://obeliskgate.com) is a conformant OpenID Connect provider. This
|
|
4
|
+
SDK is the Python counterpart of the JavaScript ``@vaultspark/obelisk-auth``
|
|
5
|
+
package: it gives any service login + tokens + refresh + userinfo + **local**
|
|
6
|
+
token verification by pointing it at an Obelisk issuer. No per-service auth
|
|
7
|
+
engine, no hand-rolled crypto.
|
|
8
|
+
|
|
9
|
+
The agent-era angle
|
|
10
|
+
-------------------
|
|
11
|
+
The interesting property for autonomous agents is that **every token can be
|
|
12
|
+
verified locally, with zero network round-trips per request**. Obelisk signs
|
|
13
|
+
ES256 (ECDSA P-256 / SHA-256) JWTs and publishes its public keys at
|
|
14
|
+
``/.well-known/jwks.json``; this SDK fetches that JWKS once, caches it by ``kid``,
|
|
15
|
+
and verifies signatures in-process (refetching only on key rotation). An agent
|
|
16
|
+
holding a token can therefore prove the token's authenticity offline.
|
|
17
|
+
|
|
18
|
+
Beyond plain authenticity, Obelisk's **id_token** carries an ``obelisk`` claim
|
|
19
|
+
block::
|
|
20
|
+
|
|
21
|
+
"obelisk": {
|
|
22
|
+
"v": "obelisk-claims-v1",
|
|
23
|
+
"assurance": "passkey", # HOW the user proved themselves
|
|
24
|
+
"anchor": "<32-hex>", # receipt anchor binding this issuance
|
|
25
|
+
"rating": { ... }, # the issuer's live security posture
|
|
26
|
+
"tenant": "<id>" # optional tenant binding
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
``assurance`` tells a relying party *how* the principal proved themselves
|
|
30
|
+
(passkey is operator-proof; sole-factor TOTP is not), and ``anchor`` ties the
|
|
31
|
+
issuance to Obelisk's tamper-evident receipt chain. Use :func:`obelisk_block`
|
|
32
|
+
to pull this block out of verified claims and gate capability on it.
|
|
33
|
+
|
|
34
|
+
Standard-library-first
|
|
35
|
+
-----------------------
|
|
36
|
+
HTTP uses ``urllib`` from the stdlib. The only third-party dependencies are
|
|
37
|
+
``cryptography`` and ``PyJWT`` (declared in ``pyproject.toml``) — used solely so
|
|
38
|
+
the ES256 signature verification reuses battle-tested JOSE crypto rather than
|
|
39
|
+
re-implementing it.
|
|
40
|
+
|
|
41
|
+
Quickstart
|
|
42
|
+
----------
|
|
43
|
+
>>> from obelisk_auth import ObeliskAuth
|
|
44
|
+
>>> auth = ObeliskAuth(
|
|
45
|
+
... issuer="https://obeliskgate.com",
|
|
46
|
+
... client_id="rp-statvault",
|
|
47
|
+
... redirect_uri="https://statvault.org/auth/callback",
|
|
48
|
+
... )
|
|
49
|
+
>>> login = auth.begin_login(scope="openid profile offline_access")
|
|
50
|
+
>>> # persist login.code_verifier + login.state + login.nonce in the user session,
|
|
51
|
+
>>> # then redirect the browser to login.authorization_url ...
|
|
52
|
+
>>> # ... on the callback (after validating that `state` matches what you stashed):
|
|
53
|
+
>>> result = auth.complete_login(code, login.code_verifier, nonce=login.nonce)
|
|
54
|
+
>>> result.claims["sub"] # doctest: +SKIP
|
|
55
|
+
'founder'
|
|
56
|
+
>>> # protect a route with a Bearer token, no network per request:
|
|
57
|
+
>>> v = auth.verify_access_token(bearer_token) # doctest: +SKIP
|
|
58
|
+
>>> v.ok # doctest: +SKIP
|
|
59
|
+
True
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import base64
|
|
65
|
+
import hashlib
|
|
66
|
+
import json
|
|
67
|
+
import secrets
|
|
68
|
+
import urllib.parse
|
|
69
|
+
import urllib.request
|
|
70
|
+
from dataclasses import dataclass, field
|
|
71
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
72
|
+
|
|
73
|
+
import jwt as pyjwt
|
|
74
|
+
from jwt import PyJWK, PyJWKSet
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"ObeliskAuth",
|
|
78
|
+
"LoginStart",
|
|
79
|
+
"LoginResult",
|
|
80
|
+
"VerifyResult",
|
|
81
|
+
"ObeliskError",
|
|
82
|
+
"pkce_challenge_s256",
|
|
83
|
+
"obelisk_block",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
__version__ = "1.0.0"
|
|
87
|
+
|
|
88
|
+
# A fetch function: (url, *, method, headers, body) -> (status, parsed_json).
|
|
89
|
+
FetchFn = Callable[..., Any]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ObeliskError(Exception):
|
|
93
|
+
"""Raised when an Obelisk operation fails (token exchange, verification)."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── value objects ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class LoginStart:
|
|
101
|
+
"""The output of :meth:`ObeliskAuth.begin_login`.
|
|
102
|
+
|
|
103
|
+
``code_verifier``, ``state`` and ``nonce`` MUST be persisted in the user's
|
|
104
|
+
session and used to validate the callback. ``authorization_url`` is where the
|
|
105
|
+
browser is redirected.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
authorization_url: str
|
|
109
|
+
code_verifier: str
|
|
110
|
+
state: str
|
|
111
|
+
nonce: str
|
|
112
|
+
|
|
113
|
+
def __iter__(self): # allow tuple-unpacking: url, verifier, state, nonce = login
|
|
114
|
+
yield self.authorization_url
|
|
115
|
+
yield self.code_verifier
|
|
116
|
+
yield self.state
|
|
117
|
+
yield self.nonce
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class LoginResult:
|
|
122
|
+
"""The output of :meth:`ObeliskAuth.complete_login`."""
|
|
123
|
+
|
|
124
|
+
tokens: Dict[str, Any]
|
|
125
|
+
claims: Dict[str, Any]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class VerifyResult:
|
|
130
|
+
"""The output of :meth:`ObeliskAuth.verify_access_token`."""
|
|
131
|
+
|
|
132
|
+
ok: bool
|
|
133
|
+
claims: Dict[str, Any] = field(default_factory=dict)
|
|
134
|
+
reason: Optional[str] = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── PKCE (RFC 7636, S256 only) ───────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _b64u_no_pad(raw: bytes) -> str:
|
|
141
|
+
"""base64url encode without padding (the JOSE / RFC 7636 convention)."""
|
|
142
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def pkce_challenge_s256(verifier: str) -> str:
|
|
146
|
+
"""Return the S256 code challenge for a verifier.
|
|
147
|
+
|
|
148
|
+
Matches the Gate's ``pkceChallengeS256`` in ``auth-kit/src/oidc.js`` exactly::
|
|
149
|
+
|
|
150
|
+
base64url( SHA-256( utf8(verifier) ) ) # no padding
|
|
151
|
+
|
|
152
|
+
The Gate verifies with ``verifyPkceS256`` (constant-time compare of this same
|
|
153
|
+
value), so the challenge we send here is precisely what it recomputes from the
|
|
154
|
+
``code_verifier`` at the token endpoint.
|
|
155
|
+
"""
|
|
156
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
157
|
+
return _b64u_no_pad(digest)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── obelisk claim block helper ───────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def obelisk_block(claims: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
164
|
+
"""Extract the ``obelisk`` assurance claim block from verified claims.
|
|
165
|
+
|
|
166
|
+
Returns the block (``{"v", "assurance", "anchor", "rating", ...}``) or
|
|
167
|
+
``None`` if the token carries no such block. Only trust this AFTER the
|
|
168
|
+
claims have been verified (e.g. via :meth:`ObeliskAuth.verify_access_token`
|
|
169
|
+
or :meth:`ObeliskAuth.verify_id_token`).
|
|
170
|
+
"""
|
|
171
|
+
block = claims.get("obelisk")
|
|
172
|
+
return block if isinstance(block, dict) else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── HTTP (stdlib urllib) ─────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _default_fetch(
|
|
179
|
+
url: str,
|
|
180
|
+
*,
|
|
181
|
+
method: str = "GET",
|
|
182
|
+
headers: Optional[Dict[str, str]] = None,
|
|
183
|
+
body: Optional[str] = None,
|
|
184
|
+
timeout: float = 15.0,
|
|
185
|
+
) -> Any:
|
|
186
|
+
"""Minimal JSON fetch over ``urllib``. Returns parsed JSON (any type)."""
|
|
187
|
+
data = body.encode("utf-8") if body is not None else None
|
|
188
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
|
189
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (trusted issuer)
|
|
190
|
+
raw = resp.read().decode("utf-8")
|
|
191
|
+
return json.loads(raw) if raw else {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── the client ───────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ObeliskAuth:
|
|
198
|
+
"""An OIDC client for an Obelisk Gate issuer.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
issuer:
|
|
203
|
+
The Obelisk Gate origin, e.g. ``https://obeliskgate.com``.
|
|
204
|
+
client_id:
|
|
205
|
+
This relying party's ``client_id`` (also the token audience).
|
|
206
|
+
redirect_uri:
|
|
207
|
+
The registered callback URL for this relying party.
|
|
208
|
+
client_secret:
|
|
209
|
+
Optional confidential-client secret (sent as ``client_secret`` form
|
|
210
|
+
field). Public PKCE clients leave this ``None``.
|
|
211
|
+
fetch_impl:
|
|
212
|
+
Optional HTTP override with signature
|
|
213
|
+
``(url, *, method, headers, body) -> parsed_json``. Defaults to a stdlib
|
|
214
|
+
``urllib`` implementation. Override it to inject mocks in tests.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
issuer: str,
|
|
221
|
+
client_id: str,
|
|
222
|
+
redirect_uri: str,
|
|
223
|
+
client_secret: Optional[str] = None,
|
|
224
|
+
fetch_impl: Optional[FetchFn] = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
if not issuer or not client_id:
|
|
227
|
+
raise ValueError("obelisk-auth: issuer + client_id required")
|
|
228
|
+
self._base = str(issuer).rstrip("/")
|
|
229
|
+
self.issuer = issuer
|
|
230
|
+
self.client_id = client_id
|
|
231
|
+
self.redirect_uri = redirect_uri
|
|
232
|
+
self._client_secret = client_secret
|
|
233
|
+
self._fetch = fetch_impl or _default_fetch
|
|
234
|
+
self._discovery: Optional[Dict[str, Any]] = None
|
|
235
|
+
self._jwks: Optional[PyJWKSet] = None
|
|
236
|
+
|
|
237
|
+
# ── discovery + JWKS ─────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def discover(self) -> Dict[str, Any]:
|
|
240
|
+
"""Fetch (and cache) the OIDC discovery document (RFC 8414)."""
|
|
241
|
+
if self._discovery is None:
|
|
242
|
+
url = f"{self._base}/.well-known/openid-configuration"
|
|
243
|
+
self._discovery = self._fetch(url, method="GET")
|
|
244
|
+
return self._discovery
|
|
245
|
+
|
|
246
|
+
def get_jwks(self, force: bool = False) -> PyJWKSet:
|
|
247
|
+
"""Fetch (and cache) the issuer JWKS. ``force`` refetches on rotation."""
|
|
248
|
+
if self._jwks is None or force:
|
|
249
|
+
d = self.discover()
|
|
250
|
+
url = d.get("jwks_uri") or f"{self._base}/.well-known/jwks.json"
|
|
251
|
+
raw = self._fetch(url, method="GET")
|
|
252
|
+
self._jwks = PyJWKSet.from_dict(raw)
|
|
253
|
+
return self._jwks
|
|
254
|
+
|
|
255
|
+
def _key_for(self, token: str) -> PyJWK:
|
|
256
|
+
"""Resolve the signing key for a token by ``kid``; refetch once on miss."""
|
|
257
|
+
header = pyjwt.get_unverified_header(token)
|
|
258
|
+
kid = header.get("kid")
|
|
259
|
+
try:
|
|
260
|
+
return self.get_jwks().__getitem__(kid) if kid else self._single_key(self.get_jwks())
|
|
261
|
+
except (KeyError, pyjwt.PyJWKSetError):
|
|
262
|
+
# key rotated → refetch the JWKS once, then retry. Normalize a final
|
|
263
|
+
# miss to PyJWKSetError so callers have one exception type to handle.
|
|
264
|
+
jwks = self.get_jwks(force=True)
|
|
265
|
+
try:
|
|
266
|
+
return jwks[kid] if kid else self._single_key(jwks)
|
|
267
|
+
except KeyError as exc:
|
|
268
|
+
raise pyjwt.PyJWKSetError(
|
|
269
|
+
f"no key for kid: {kid}"
|
|
270
|
+
) from exc
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _single_key(jwks: PyJWKSet) -> PyJWK:
|
|
274
|
+
keys = list(jwks.keys)
|
|
275
|
+
if not keys:
|
|
276
|
+
raise ObeliskError("obelisk-auth: empty JWKS")
|
|
277
|
+
return keys[0]
|
|
278
|
+
|
|
279
|
+
# ── login start ──────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def begin_login(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
scope: str = "openid profile",
|
|
285
|
+
extra: Optional[Dict[str, str]] = None,
|
|
286
|
+
) -> LoginStart:
|
|
287
|
+
"""Start an authorization-code + PKCE (S256) login.
|
|
288
|
+
|
|
289
|
+
Returns a :class:`LoginStart` whose ``code_verifier``, ``state`` and
|
|
290
|
+
``nonce`` the caller MUST persist in the user's session to validate the
|
|
291
|
+
callback. Redirect the browser to ``authorization_url``.
|
|
292
|
+
"""
|
|
293
|
+
code_verifier = secrets.token_urlsafe(32)
|
|
294
|
+
state = secrets.token_urlsafe(16)
|
|
295
|
+
nonce = secrets.token_urlsafe(16)
|
|
296
|
+
d = self.discover()
|
|
297
|
+
endpoint = d.get("authorization_endpoint") or f"{self._base}/auth/authorize"
|
|
298
|
+
params = {
|
|
299
|
+
"response_type": "code",
|
|
300
|
+
"client_id": self.client_id,
|
|
301
|
+
"redirect_uri": self.redirect_uri,
|
|
302
|
+
"scope": scope,
|
|
303
|
+
"state": state,
|
|
304
|
+
"nonce": nonce,
|
|
305
|
+
"code_challenge": pkce_challenge_s256(code_verifier),
|
|
306
|
+
"code_challenge_method": "S256", # Obelisk accepts S256 ONLY
|
|
307
|
+
}
|
|
308
|
+
if extra:
|
|
309
|
+
params.update(extra)
|
|
310
|
+
url = f"{endpoint}?{urllib.parse.urlencode(params)}"
|
|
311
|
+
return LoginStart(
|
|
312
|
+
authorization_url=url,
|
|
313
|
+
code_verifier=code_verifier,
|
|
314
|
+
state=state,
|
|
315
|
+
nonce=nonce,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# ── login completion ─────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
def complete_login(
|
|
321
|
+
self,
|
|
322
|
+
code: str,
|
|
323
|
+
code_verifier: str,
|
|
324
|
+
*,
|
|
325
|
+
nonce: Optional[str] = None,
|
|
326
|
+
) -> LoginResult:
|
|
327
|
+
"""Exchange a callback ``code`` for tokens and verify the id_token.
|
|
328
|
+
|
|
329
|
+
Pass the ``code_verifier`` and ``nonce`` stashed in
|
|
330
|
+
:meth:`begin_login`. The id_token signature, issuer, audience and (if
|
|
331
|
+
provided) nonce are all verified locally. Raises :class:`ObeliskError`
|
|
332
|
+
on any failure.
|
|
333
|
+
"""
|
|
334
|
+
d = self.discover()
|
|
335
|
+
endpoint = d.get("token_endpoint") or f"{self._base}/auth/token"
|
|
336
|
+
form = {
|
|
337
|
+
"grant_type": "authorization_code",
|
|
338
|
+
"code": code,
|
|
339
|
+
"redirect_uri": self.redirect_uri,
|
|
340
|
+
"client_id": self.client_id,
|
|
341
|
+
"code_verifier": code_verifier,
|
|
342
|
+
}
|
|
343
|
+
if self._client_secret:
|
|
344
|
+
form["client_secret"] = self._client_secret
|
|
345
|
+
tokens = self._fetch(
|
|
346
|
+
endpoint,
|
|
347
|
+
method="POST",
|
|
348
|
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
|
349
|
+
body=urllib.parse.urlencode(form),
|
|
350
|
+
)
|
|
351
|
+
if not tokens or tokens.get("error"):
|
|
352
|
+
raise ObeliskError(
|
|
353
|
+
f"obelisk-auth: token exchange failed ({tokens and tokens.get('error')})"
|
|
354
|
+
)
|
|
355
|
+
claims = self.verify_id_token(tokens["id_token"], nonce=nonce)
|
|
356
|
+
return LoginResult(tokens=tokens, claims=claims)
|
|
357
|
+
|
|
358
|
+
# ── verification ─────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
def _decode(
|
|
361
|
+
self,
|
|
362
|
+
token: str,
|
|
363
|
+
*,
|
|
364
|
+
audience: Optional[str] = None,
|
|
365
|
+
) -> Dict[str, Any]:
|
|
366
|
+
"""Verify an ES256 JWT locally against the cached JWKS.
|
|
367
|
+
|
|
368
|
+
Checks signature + ``exp``/``nbf`` + ``iss`` (+ ``aud`` when given). Raises
|
|
369
|
+
``pyjwt`` exceptions on failure; callers translate them.
|
|
370
|
+
"""
|
|
371
|
+
key = self._key_for(token)
|
|
372
|
+
options = {"require": ["exp", "iss"]}
|
|
373
|
+
return pyjwt.decode(
|
|
374
|
+
token,
|
|
375
|
+
key=key.key,
|
|
376
|
+
algorithms=["ES256"], # Obelisk signs ES256 ONLY
|
|
377
|
+
issuer=self._base,
|
|
378
|
+
audience=audience,
|
|
379
|
+
options=options if audience else {"require": ["exp", "iss"], "verify_aud": False},
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def verify_id_token(
|
|
383
|
+
self,
|
|
384
|
+
id_token: str,
|
|
385
|
+
*,
|
|
386
|
+
nonce: Optional[str] = None,
|
|
387
|
+
) -> Dict[str, Any]:
|
|
388
|
+
"""Verify an id_token (signature + iss + aud + optional nonce).
|
|
389
|
+
|
|
390
|
+
Returns the verified claims. Raises :class:`ObeliskError` if invalid or
|
|
391
|
+
if the nonce does not match.
|
|
392
|
+
"""
|
|
393
|
+
try:
|
|
394
|
+
claims = self._decode(id_token, audience=self.client_id)
|
|
395
|
+
except pyjwt.PyJWTError as exc:
|
|
396
|
+
raise ObeliskError(f"obelisk-auth: id_token invalid ({exc})") from exc
|
|
397
|
+
if nonce is not None and claims.get("nonce") != nonce:
|
|
398
|
+
raise ObeliskError("obelisk-auth: nonce mismatch")
|
|
399
|
+
return claims
|
|
400
|
+
|
|
401
|
+
def verify_access_token(self, access_token: str) -> VerifyResult:
|
|
402
|
+
"""Verify a Bearer access token locally (no network per request).
|
|
403
|
+
|
|
404
|
+
Returns a :class:`VerifyResult`; ``ok=False`` carries a ``reason`` rather
|
|
405
|
+
than raising, so it is convenient to use in request-path guards.
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
claims = self._decode(access_token, audience=None)
|
|
409
|
+
except pyjwt.ExpiredSignatureError:
|
|
410
|
+
return VerifyResult(ok=False, reason="expired")
|
|
411
|
+
except pyjwt.ImmatureSignatureError:
|
|
412
|
+
return VerifyResult(ok=False, reason="not-yet-valid")
|
|
413
|
+
except pyjwt.InvalidIssuerError:
|
|
414
|
+
return VerifyResult(ok=False, reason="issuer-mismatch")
|
|
415
|
+
except (pyjwt.PyJWKSetError, KeyError):
|
|
416
|
+
# No JWK matches the token's kid (even after a forced refetch) —
|
|
417
|
+
# the token was signed by a key this issuer does not publish.
|
|
418
|
+
return VerifyResult(ok=False, reason="no-key")
|
|
419
|
+
except pyjwt.PyJWTError as exc:
|
|
420
|
+
return VerifyResult(ok=False, reason=str(exc) or "invalid")
|
|
421
|
+
return VerifyResult(ok=True, claims=claims)
|
|
422
|
+
|
|
423
|
+
# ── refresh + userinfo ───────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
def refresh(self, refresh_token: str) -> Dict[str, Any]:
|
|
426
|
+
"""Rotate a refresh token.
|
|
427
|
+
|
|
428
|
+
Obelisk rotates the token (returns a fresh one) and revokes the entire
|
|
429
|
+
family on reuse of a superseded token. Returns the raw token response.
|
|
430
|
+
"""
|
|
431
|
+
d = self.discover()
|
|
432
|
+
endpoint = d.get("token_endpoint") or f"{self._base}/auth/token"
|
|
433
|
+
form = {
|
|
434
|
+
"grant_type": "refresh_token",
|
|
435
|
+
"refresh_token": refresh_token,
|
|
436
|
+
"client_id": self.client_id,
|
|
437
|
+
}
|
|
438
|
+
if self._client_secret:
|
|
439
|
+
form["client_secret"] = self._client_secret
|
|
440
|
+
return self._fetch(
|
|
441
|
+
endpoint,
|
|
442
|
+
method="POST",
|
|
443
|
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
|
444
|
+
body=urllib.parse.urlencode(form),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def get_userinfo(self, access_token: str) -> Dict[str, Any]:
|
|
448
|
+
"""Fetch the userinfo claims for a Bearer access token."""
|
|
449
|
+
d = self.discover()
|
|
450
|
+
endpoint = d.get("userinfo_endpoint") or f"{self._base}/auth/userinfo"
|
|
451
|
+
return self._fetch(
|
|
452
|
+
endpoint,
|
|
453
|
+
method="GET",
|
|
454
|
+
headers={"authorization": f"Bearer {access_token}"},
|
|
455
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "obelisk-auth"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Drop-in OIDC client for the Obelisk Gate identity platform — login, tokens, refresh, userinfo, and local ES256 token verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
# S321 — the license TEXT must ship, not just the word "Proprietary".
|
|
13
|
+
#
|
|
14
|
+
# This package declared a proprietary license and carried no LICENSE file, so a public
|
|
15
|
+
# PyPI release would have reached users with terms asserted and nowhere stated. The npm
|
|
16
|
+
# SDK has always shipped LICENSE + NOTICE + TRADEMARKS in its allowlist; the Python SDK
|
|
17
|
+
# was declaring the same posture without the documents behind it. "Proprietary" with no
|
|
18
|
+
# text is not a weaker license, it is an unstated one.
|
|
19
|
+
license-files = ["LICENSE", "NOTICE"]
|
|
20
|
+
authors = [{ name = "VaultSpark Studios" }]
|
|
21
|
+
keywords = ["oidc", "openid-connect", "obelisk", "auth", "pkce", "es256", "jwt"]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Standard-library-first: HTTP is stdlib urllib. The only third-party deps are
|
|
30
|
+
# the JOSE crypto stack — we never hand-roll signature verification.
|
|
31
|
+
dependencies = [
|
|
32
|
+
"PyJWT>=2.8.0",
|
|
33
|
+
"cryptography>=42.0.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://obeliskgate.com"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
test = ["pytest>=7.0"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["obelisk_auth"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Unit tests for obelisk_auth — no live server required.
|
|
2
|
+
|
|
3
|
+
Coverage:
|
|
4
|
+
* PKCE-S256 challenge correctness (known-answer + matches Gate's verifyPkceS256).
|
|
5
|
+
* Authorization-URL construction (endpoints, PKCE params, S256-only).
|
|
6
|
+
* Discovery + JWKS caching.
|
|
7
|
+
* Full login flow against a mocked /auth/token, with a locally-minted ES256
|
|
8
|
+
id_token verified through the real local-verification path.
|
|
9
|
+
* Local access-token verification: valid, wrong-issuer, expired, tampered.
|
|
10
|
+
* obelisk_block extraction.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
import jwt as pyjwt
|
|
22
|
+
import pytest
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
24
|
+
|
|
25
|
+
from obelisk_auth import (
|
|
26
|
+
LoginStart,
|
|
27
|
+
ObeliskAuth,
|
|
28
|
+
ObeliskError,
|
|
29
|
+
obelisk_block,
|
|
30
|
+
pkce_challenge_s256,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ISSUER = "https://gate.test"
|
|
34
|
+
CLIENT_ID = "rp-statvault"
|
|
35
|
+
REDIRECT = "https://statvault.example/cb"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── helpers: a minimal in-test Gate (ES256 signer + JWKS) ────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _b64u(raw: bytes) -> str:
|
|
42
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FakeGate:
|
|
46
|
+
"""An ES256 signing key + its JWKS, mirroring how Obelisk signs tokens."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._priv = ec.generate_private_key(ec.SECP256R1())
|
|
50
|
+
# Build the public JWK the way the Gate publishes it.
|
|
51
|
+
pub_jwk = json.loads(pyjwt.algorithms.ECAlgorithm.to_jwk(self._priv.public_key()))
|
|
52
|
+
canonical = json.dumps(
|
|
53
|
+
{"crv": pub_jwk["crv"], "kty": pub_jwk["kty"], "x": pub_jwk["x"], "y": pub_jwk["y"]},
|
|
54
|
+
separators=(",", ":"),
|
|
55
|
+
)
|
|
56
|
+
self.kid = _b64u(hashlib.sha256(canonical.encode()).digest())
|
|
57
|
+
pub_jwk.update({"use": "sig", "alg": "ES256", "kid": self.kid})
|
|
58
|
+
self.jwks = {"keys": [pub_jwk]}
|
|
59
|
+
|
|
60
|
+
def sign(self, claims: Dict[str, Any]) -> str:
|
|
61
|
+
return pyjwt.encode(
|
|
62
|
+
claims, self._priv, algorithm="ES256", headers={"kid": self.kid}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def id_token(self, *, sub: str, nonce: Optional[str] = None, **extra: Any) -> str:
|
|
66
|
+
now = int(time.time())
|
|
67
|
+
claims: Dict[str, Any] = {
|
|
68
|
+
"iss": ISSUER,
|
|
69
|
+
"sub": sub,
|
|
70
|
+
"aud": CLIENT_ID,
|
|
71
|
+
"iat": now,
|
|
72
|
+
"exp": now + 3600,
|
|
73
|
+
"obelisk": {
|
|
74
|
+
"v": "obelisk-claims-v1",
|
|
75
|
+
"assurance": "passkey",
|
|
76
|
+
"anchor": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
|
|
77
|
+
"rating": {"rating": 91.4, "band": "fortress"},
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
if nonce is not None:
|
|
81
|
+
claims["nonce"] = nonce
|
|
82
|
+
claims.update(extra)
|
|
83
|
+
return self.sign(claims)
|
|
84
|
+
|
|
85
|
+
def access_token(self, *, sub: str, scope: str = "openid profile") -> str:
|
|
86
|
+
now = int(time.time())
|
|
87
|
+
return self.sign(
|
|
88
|
+
{"iss": ISSUER, "sub": sub, "aud": CLIENT_ID, "iat": now, "exp": now + 3600, "scope": scope}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def make_fetch(gate: FakeGate, *, token_response: Dict[str, Any]):
|
|
93
|
+
"""A fetch_impl that serves discovery, JWKS, token and userinfo locally."""
|
|
94
|
+
calls: List[str] = []
|
|
95
|
+
|
|
96
|
+
def fetch(url: str, *, method: str = "GET", headers=None, body=None):
|
|
97
|
+
calls.append(f"{method} {url}")
|
|
98
|
+
if url.endswith("/.well-known/openid-configuration"):
|
|
99
|
+
return {
|
|
100
|
+
"issuer": ISSUER,
|
|
101
|
+
"authorization_endpoint": f"{ISSUER}/auth/authorize",
|
|
102
|
+
"token_endpoint": f"{ISSUER}/auth/token",
|
|
103
|
+
"userinfo_endpoint": f"{ISSUER}/auth/userinfo",
|
|
104
|
+
"jwks_uri": f"{ISSUER}/.well-known/jwks.json",
|
|
105
|
+
"code_challenge_methods_supported": ["S256"],
|
|
106
|
+
}
|
|
107
|
+
if url.endswith("/.well-known/jwks.json"):
|
|
108
|
+
return gate.jwks
|
|
109
|
+
if url.endswith("/auth/token"):
|
|
110
|
+
return token_response
|
|
111
|
+
if url.endswith("/auth/userinfo"):
|
|
112
|
+
return {"sub": "founder", "name": "Founder"}
|
|
113
|
+
raise AssertionError(f"unexpected fetch: {url}")
|
|
114
|
+
|
|
115
|
+
fetch.calls = calls # type: ignore[attr-defined]
|
|
116
|
+
return fetch
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def make_auth(gate: FakeGate, token_response: Dict[str, Any]) -> ObeliskAuth:
|
|
120
|
+
return ObeliskAuth(
|
|
121
|
+
issuer=ISSUER,
|
|
122
|
+
client_id=CLIENT_ID,
|
|
123
|
+
redirect_uri=REDIRECT,
|
|
124
|
+
fetch_impl=make_fetch(gate, token_response=token_response),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── PKCE ──────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_pkce_challenge_known_answer():
|
|
132
|
+
# RFC 7636 Appendix B canonical verifier → challenge.
|
|
133
|
+
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
134
|
+
assert pkce_challenge_s256(verifier) == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_pkce_challenge_is_unpadded_base64url_sha256():
|
|
138
|
+
verifier = "a-test-verifier-string"
|
|
139
|
+
expected = (
|
|
140
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
141
|
+
.rstrip(b"=")
|
|
142
|
+
.decode("ascii")
|
|
143
|
+
)
|
|
144
|
+
challenge = pkce_challenge_s256(verifier)
|
|
145
|
+
assert challenge == expected
|
|
146
|
+
assert "=" not in challenge # JOSE: no padding
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_pkce_matches_gate_verify_pkce_s256():
|
|
150
|
+
# The Gate's verifyPkceS256 recomputes base64url(sha256(verifier)) and
|
|
151
|
+
# constant-time compares to the stored challenge. Emulate that check here.
|
|
152
|
+
verifier = "another_verifier_0123456789"
|
|
153
|
+
challenge = pkce_challenge_s256(verifier)
|
|
154
|
+
recomputed = pkce_challenge_s256(verifier)
|
|
155
|
+
assert challenge == recomputed # the Gate would accept this verifier
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── authorization URL ──────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_begin_login_builds_authorize_url():
|
|
162
|
+
gate = FakeGate()
|
|
163
|
+
auth = make_auth(gate, token_response={})
|
|
164
|
+
login = auth.begin_login(scope="openid profile offline_access")
|
|
165
|
+
|
|
166
|
+
assert isinstance(login, LoginStart)
|
|
167
|
+
from urllib.parse import urlparse, parse_qs
|
|
168
|
+
|
|
169
|
+
parsed = urlparse(login.authorization_url)
|
|
170
|
+
assert parsed.scheme + "://" + parsed.netloc + parsed.path == f"{ISSUER}/auth/authorize"
|
|
171
|
+
q = parse_qs(parsed.query)
|
|
172
|
+
assert q["response_type"] == ["code"]
|
|
173
|
+
assert q["client_id"] == [CLIENT_ID]
|
|
174
|
+
assert q["redirect_uri"] == [REDIRECT]
|
|
175
|
+
assert q["scope"] == ["openid profile offline_access"]
|
|
176
|
+
assert q["code_challenge_method"] == ["S256"] # S256 ONLY
|
|
177
|
+
assert q["state"] == [login.state]
|
|
178
|
+
assert q["nonce"] == [login.nonce]
|
|
179
|
+
# the challenge in the URL is exactly S256(verifier)
|
|
180
|
+
assert q["code_challenge"] == [pkce_challenge_s256(login.code_verifier)]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_begin_login_values_are_unique_each_call():
|
|
184
|
+
gate = FakeGate()
|
|
185
|
+
auth = make_auth(gate, token_response={})
|
|
186
|
+
a = auth.begin_login()
|
|
187
|
+
b = auth.begin_login()
|
|
188
|
+
assert a.code_verifier != b.code_verifier
|
|
189
|
+
assert a.state != b.state
|
|
190
|
+
assert a.nonce != b.nonce
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_login_start_tuple_unpacking():
|
|
194
|
+
gate = FakeGate()
|
|
195
|
+
auth = make_auth(gate, token_response={})
|
|
196
|
+
url, verifier, state, nonce = auth.begin_login()
|
|
197
|
+
assert url.startswith(f"{ISSUER}/auth/authorize?")
|
|
198
|
+
assert verifier and state and nonce
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── discovery + jwks caching ───────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_discover_caches():
|
|
205
|
+
gate = FakeGate()
|
|
206
|
+
auth = make_auth(gate, token_response={})
|
|
207
|
+
d1 = auth.discover()
|
|
208
|
+
d2 = auth.discover()
|
|
209
|
+
assert d1["issuer"] == ISSUER
|
|
210
|
+
assert d1 is d2 # cached object
|
|
211
|
+
disc_calls = [c for c in auth._fetch.calls if "openid-configuration" in c]
|
|
212
|
+
assert len(disc_calls) == 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_jwks_caches_and_force_refetches():
|
|
216
|
+
gate = FakeGate()
|
|
217
|
+
auth = make_auth(gate, token_response={})
|
|
218
|
+
auth.get_jwks()
|
|
219
|
+
auth.get_jwks()
|
|
220
|
+
jwks_calls = [c for c in auth._fetch.calls if "jwks.json" in c]
|
|
221
|
+
assert len(jwks_calls) == 1
|
|
222
|
+
auth.get_jwks(force=True)
|
|
223
|
+
jwks_calls = [c for c in auth._fetch.calls if "jwks.json" in c]
|
|
224
|
+
assert len(jwks_calls) == 2
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── full login flow ─────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_complete_login_verifies_id_token():
|
|
231
|
+
gate = FakeGate()
|
|
232
|
+
nonce = "nonce-xyz"
|
|
233
|
+
id_token = gate.id_token(sub="founder", nonce=nonce)
|
|
234
|
+
access = gate.access_token(sub="founder")
|
|
235
|
+
auth = make_auth(
|
|
236
|
+
gate,
|
|
237
|
+
token_response={
|
|
238
|
+
"id_token": id_token,
|
|
239
|
+
"access_token": access,
|
|
240
|
+
"refresh_token": "rt-1",
|
|
241
|
+
"token_type": "Bearer",
|
|
242
|
+
"expires_in": 3600,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
result = auth.complete_login("the-code", "the-verifier", nonce=nonce)
|
|
246
|
+
assert result.claims["sub"] == "founder"
|
|
247
|
+
assert result.claims["aud"] == CLIENT_ID
|
|
248
|
+
assert result.tokens["access_token"] == access
|
|
249
|
+
# the obelisk assurance block survives verification
|
|
250
|
+
block = obelisk_block(result.claims)
|
|
251
|
+
assert block is not None
|
|
252
|
+
assert block["assurance"] == "passkey"
|
|
253
|
+
assert len(block["anchor"]) == 32
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_complete_login_rejects_nonce_mismatch():
|
|
257
|
+
gate = FakeGate()
|
|
258
|
+
id_token = gate.id_token(sub="founder", nonce="real-nonce")
|
|
259
|
+
auth = make_auth(gate, token_response={"id_token": id_token, "access_token": "x"})
|
|
260
|
+
with pytest.raises(ObeliskError, match="nonce mismatch"):
|
|
261
|
+
auth.complete_login("code", "verifier", nonce="WRONG")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_complete_login_raises_on_token_error():
|
|
265
|
+
gate = FakeGate()
|
|
266
|
+
auth = make_auth(gate, token_response={"error": "invalid_grant"})
|
|
267
|
+
with pytest.raises(ObeliskError, match="token exchange failed"):
|
|
268
|
+
auth.complete_login("code", "verifier")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── local access-token verification ─────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_verify_access_token_valid():
|
|
275
|
+
gate = FakeGate()
|
|
276
|
+
auth = make_auth(gate, token_response={})
|
|
277
|
+
token = gate.access_token(sub="founder")
|
|
278
|
+
v = auth.verify_access_token(token)
|
|
279
|
+
assert v.ok is True
|
|
280
|
+
assert v.claims["sub"] == "founder"
|
|
281
|
+
assert v.reason is None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_verify_access_token_rejects_wrong_issuer_key():
|
|
285
|
+
# A token from a DIFFERENT gate (different signing key) must not verify.
|
|
286
|
+
gate_a = FakeGate()
|
|
287
|
+
gate_b = FakeGate()
|
|
288
|
+
auth = make_auth(gate_a, token_response={}) # auth trusts gate A's JWKS
|
|
289
|
+
forged = gate_b.access_token(sub="founder")
|
|
290
|
+
v = auth.verify_access_token(forged)
|
|
291
|
+
assert v.ok is False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_verify_access_token_expired():
|
|
295
|
+
gate = FakeGate()
|
|
296
|
+
auth = make_auth(gate, token_response={})
|
|
297
|
+
now = int(time.time())
|
|
298
|
+
token = gate.sign(
|
|
299
|
+
{"iss": ISSUER, "sub": "founder", "aud": CLIENT_ID, "iat": now - 7200, "exp": now - 3600}
|
|
300
|
+
)
|
|
301
|
+
v = auth.verify_access_token(token)
|
|
302
|
+
assert v.ok is False
|
|
303
|
+
assert v.reason == "expired"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_verify_access_token_tampered_payload():
|
|
307
|
+
gate = FakeGate()
|
|
308
|
+
auth = make_auth(gate, token_response={})
|
|
309
|
+
token = gate.access_token(sub="founder")
|
|
310
|
+
header, payload, sig = token.split(".")
|
|
311
|
+
tampered_claims = base64.urlsafe_b64encode(
|
|
312
|
+
json.dumps({"iss": ISSUER, "sub": "attacker", "aud": CLIENT_ID, "exp": int(time.time()) + 3600}).encode()
|
|
313
|
+
).rstrip(b"=").decode()
|
|
314
|
+
forged = f"{header}.{tampered_claims}.{sig}"
|
|
315
|
+
v = auth.verify_access_token(forged)
|
|
316
|
+
assert v.ok is False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_verify_access_token_wrong_issuer_claim():
|
|
320
|
+
gate = FakeGate()
|
|
321
|
+
auth = make_auth(gate, token_response={})
|
|
322
|
+
now = int(time.time())
|
|
323
|
+
token = gate.sign(
|
|
324
|
+
{"iss": "https://evil.test", "sub": "founder", "aud": CLIENT_ID, "iat": now, "exp": now + 3600}
|
|
325
|
+
)
|
|
326
|
+
v = auth.verify_access_token(token)
|
|
327
|
+
assert v.ok is False
|
|
328
|
+
assert v.reason == "issuer-mismatch"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ── refresh + userinfo ──────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_refresh_posts_refresh_grant():
|
|
335
|
+
gate = FakeGate()
|
|
336
|
+
auth = make_auth(
|
|
337
|
+
gate,
|
|
338
|
+
token_response={"access_token": "at-2", "refresh_token": "rt-2", "expires_in": 3600},
|
|
339
|
+
)
|
|
340
|
+
out = auth.refresh("rt-1")
|
|
341
|
+
assert out["access_token"] == "at-2"
|
|
342
|
+
assert out["refresh_token"] == "rt-2"
|
|
343
|
+
assert any("POST" in c and "/auth/token" in c for c in auth._fetch.calls)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_get_userinfo():
|
|
347
|
+
gate = FakeGate()
|
|
348
|
+
auth = make_auth(gate, token_response={})
|
|
349
|
+
info = auth.get_userinfo("at")
|
|
350
|
+
assert info["sub"] == "founder"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ── obelisk_block helper ────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_obelisk_block_present_and_absent():
|
|
357
|
+
assert obelisk_block({"obelisk": {"assurance": "passkey"}}) == {"assurance": "passkey"}
|
|
358
|
+
assert obelisk_block({"sub": "x"}) is None
|
|
359
|
+
assert obelisk_block({"obelisk": "not-a-dict"}) is None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ── constructor validation ──────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_constructor_requires_issuer_and_client():
|
|
366
|
+
with pytest.raises(ValueError):
|
|
367
|
+
ObeliskAuth(issuer="", client_id="x", redirect_uri="y")
|
|
368
|
+
with pytest.raises(ValueError):
|
|
369
|
+
ObeliskAuth(issuer="https://x", client_id="", redirect_uri="y")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_version_agrees_with_packaging_metadata():
|
|
373
|
+
"""S321 - two owners of one fact.
|
|
374
|
+
|
|
375
|
+
The version lives in BOTH pyproject.toml and obelisk_auth.__version__. Bumping
|
|
376
|
+
0.1.0 -> 1.0.0 for the PyPI release touched the manifest first, which would have
|
|
377
|
+
shipped a package whose own __version__ disagreed with the version the registry
|
|
378
|
+
serves - the same two-spellings defect this project keeps finding elsewhere.
|
|
379
|
+
Nothing compared them, so this does.
|
|
380
|
+
"""
|
|
381
|
+
import pathlib
|
|
382
|
+
import re
|
|
383
|
+
|
|
384
|
+
import obelisk_auth
|
|
385
|
+
|
|
386
|
+
pyproject = (pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml").read_text(
|
|
387
|
+
encoding="utf-8"
|
|
388
|
+
)
|
|
389
|
+
match = re.search(r'(?m)^version = "([^"]+)"', pyproject)
|
|
390
|
+
assert match, "pyproject.toml must declare a version"
|
|
391
|
+
assert obelisk_auth.__version__ == match.group(1), (
|
|
392
|
+
"obelisk_auth.__version__ must equal the packaged version; a module that "
|
|
393
|
+
"misreports its own version is unfalsifiable from the outside"
|
|
394
|
+
)
|