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.
@@ -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
+ )