oidc-caep-risc 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. oidc_caep_risc-0.1.0/LICENSE +21 -0
  2. oidc_caep_risc-0.1.0/PKG-INFO +83 -0
  3. oidc_caep_risc-0.1.0/README.md +66 -0
  4. oidc_caep_risc-0.1.0/pyproject.toml +33 -0
  5. oidc_caep_risc-0.1.0/setup.cfg +4 -0
  6. oidc_caep_risc-0.1.0/src/oidc_caep_risc/__init__.py +59 -0
  7. oidc_caep_risc-0.1.0/src/oidc_caep_risc/b64.py +19 -0
  8. oidc_caep_risc-0.1.0/src/oidc_caep_risc/backchannel.py +79 -0
  9. oidc_caep_risc-0.1.0/src/oidc_caep_risc/cli.py +56 -0
  10. oidc_caep_risc-0.1.0/src/oidc_caep_risc/client_auth.py +59 -0
  11. oidc_caep_risc-0.1.0/src/oidc_caep_risc/config.py +222 -0
  12. oidc_caep_risc-0.1.0/src/oidc_caep_risc/discovery.py +126 -0
  13. oidc_caep_risc-0.1.0/src/oidc_caep_risc/errors.py +18 -0
  14. oidc_caep_risc-0.1.0/src/oidc_caep_risc/id_token.py +199 -0
  15. oidc_caep_risc-0.1.0/src/oidc_caep_risc/keys.py +166 -0
  16. oidc_caep_risc-0.1.0/src/oidc_caep_risc/pkce.py +19 -0
  17. oidc_caep_risc-0.1.0/src/oidc_caep_risc/relying_party.py +413 -0
  18. oidc_caep_risc-0.1.0/src/oidc_caep_risc/ssf/__init__.py +29 -0
  19. oidc_caep_risc-0.1.0/src/oidc_caep_risc/ssf/events.py +99 -0
  20. oidc_caep_risc-0.1.0/src/oidc_caep_risc/ssf/jws.py +60 -0
  21. oidc_caep_risc-0.1.0/src/oidc_caep_risc/ssf/receiver.py +329 -0
  22. oidc_caep_risc-0.1.0/src/oidc_caep_risc/ssf/session_index.py +75 -0
  23. oidc_caep_risc-0.1.0/src/oidc_caep_risc.egg-info/PKG-INFO +83 -0
  24. oidc_caep_risc-0.1.0/src/oidc_caep_risc.egg-info/SOURCES.txt +30 -0
  25. oidc_caep_risc-0.1.0/src/oidc_caep_risc.egg-info/dependency_links.txt +1 -0
  26. oidc_caep_risc-0.1.0/src/oidc_caep_risc.egg-info/entry_points.txt +2 -0
  27. oidc_caep_risc-0.1.0/src/oidc_caep_risc.egg-info/requires.txt +4 -0
  28. oidc_caep_risc-0.1.0/src/oidc_caep_risc.egg-info/top_level.txt +1 -0
  29. oidc_caep_risc-0.1.0/tests/test_password_file.py +63 -0
  30. oidc_caep_risc-0.1.0/tests/test_rp.py +519 -0
  31. oidc_caep_risc-0.1.0/tests/test_ssf.py +245 -0
  32. oidc_caep_risc-0.1.0/tests/test_trust_chain.py +177 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Iya CyberSecurity Solutions, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: oidc-caep-risc
3
+ Version: 0.1.0
4
+ Summary: Server-side OpenID Connect confidential client: Authorization Code with PKCE, client authentication, and a CAEP and RISC Shared Signals receiver.
5
+ Author: Iya CyberSecurity Solutions, LLC
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/rcbj/oidc-caep-risc-python
8
+ Project-URL: Repository, https://github.com/rcbj/oidc-caep-risc-python
9
+ Project-URL: Issues, https://github.com/rcbj/oidc-caep-risc-python/issues
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: cryptography
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == "dev"
16
+ Dynamic: license-file
17
+
18
+ # oidc-caep-risc
19
+
20
+ A confidential OpenID Connect relying party for a traditional Python application, and a CAEP and RISC receiver on one Shared Signals poll stream. Authorization Code with PKCE (S256) is required. Client authentication is `client_secret_basic`, `client_secret_post`, or `private_key_jwt`. A public client (`tokenEndpointAuthMethod` `"none"`) is refused. The only cryptographic dependency is `cryptography`.
21
+
22
+ JSON configuration keys are the same names the Node library `oidc-caep-risc-nodejs` reads. `signals` omitted or `true` keeps CAEP and RISC on. `signals: false` does not start the receiver, does not require transmitter metadata, and drops `ssf.read` and `ssf.manage` from the scope list after the default scopes are applied. `DEFAULT_SCOPES` still names those two scopes. The shipped demo leaves `signals` on (the key is omitted).
23
+
24
+ The license is MIT. `develop` and `main` are the same commit. A pull request merged into `main` publishes the package to PyPI after the test workflow succeeds on that commit. A direct push does not. There is no API token: PyPI Trusted Publishing trades the workflow's GitHub OIDC token for a short-lived upload token, from the `pypi` environment, and PyPI holds the publisher for this repository and `publish.yml`. A version already on PyPI stays there; the next release is a new `version` in `pyproject.toml` (and `VERSION`).
25
+
26
+ ## Usage
27
+
28
+ ```python
29
+ from oidc_caep_risc import RelyingParty
30
+
31
+ rp = RelyingParty.from_file("oidc.json")
32
+ request = rp.authorize()
33
+ # redirect the browser to request["url"]; keep state, nonce, and code_verifier
34
+ who = rp.complete(query, request)
35
+ ```
36
+
37
+ Tokens stay in this process. Keep the transaction until the browser returns.
38
+
39
+ ## Tests
40
+
41
+ ```bash
42
+ docker build --target test .
43
+ ```
44
+
45
+ The suite runs in a container.
46
+
47
+ The tests build an in-process stub provider and a stub transmitter. They do not start iya-sts.
48
+
49
+ `python3 scripts/live_iya_sts.py` is the live half. It starts the latest main branch of iya-sts in memory mode with persistence and clustering off, runs the install script in a scratch copy of this checkout (which writes the `tlsCa` chain), then builds this demo image from it, checks the image carries the chain, starts the demo with no CA mount and no refresh, signs in, and requires a credential-change event and a session-revoked event on `GET /live-status`. The second event ends the local session. It binds two high ports, never 3000 or 8081. The nightly workflow runs the unit suite and this script once every 24 hours.
50
+
51
+ ## Layout
52
+
53
+ | Path | What it is |
54
+ |---|---|
55
+ | `src/oidc_caep_risc` | The library. `relying_party.py`, `client_auth.py`, `id_token.py`, `pkce.py`, `discovery.py`, `config.py`, `ssf/`. |
56
+ | `tests` | pytest ports of the Node relying-party and Shared Signals tests. |
57
+ | `demo` | `demo/app.py`, a stdlib `http.server` application. Config is `demo/oidc.json`. |
58
+ | `scripts/configure_iya_sts.py` | Registers the confidential client on iya-sts and creates the test user `oidc-demo-user`. A generated client secret goes to the file `secretFile` names, and a generated password to `demo/generated/oidc-demo-user.password`, each mode 0600; the script prints neither. Loopback redirects follow the running container's `PORT`, then the shell `PORT`. |
59
+ | `Dockerfile`, `docker-compose.yml` | The demo image. Compose uses host networking. |
60
+
61
+ ## The iya-sts trust chain (`tlsCa`)
62
+
63
+ `tlsCa` in `demo/oidc.json` names the iya-sts TLS listener CA trust chain the library trusts. The default is `generated/idp-ca.pem`, relative to `demo/`. The value may also be `file:<path>`, `env:<NAME>`, or the PEM itself. The library adds every certificate in it to the default trust store.
64
+
65
+ `scripts/configure_iya_sts.py`, the install script, writes that file. It saves the whole chain iya-sts publishes at `GET /tls/server-certificate` (leaf, TLS Issuing CA, Intermediate, Root), or the chain from the TLS handshake when that path does not answer. It prints the path, the trust anchor's subject and its SHA-256 fingerprint. Compare the anchor with the Root CA on iya-sts's PKI page.
66
+
67
+ The demo image build copies that file into the image at `/app/demo/generated/idp-ca.pem`, so the image trusts that iya-sts with no bind mount and no fetch. Run the install script before you build the image. Rebuild the image after running the install script against a different iya-sts. `.dockerignore` admits only that file from `demo/generated`. The client secret stays out of the image. A build with no chain file yet (a fresh clone, CI) still succeeds; the image then has no `tlsCa` file.
68
+
69
+ The container entrypoint also refreshes the file from the provider at start, because a development iya-sts makes a new CA when it restarts. A failed refresh keeps the file from the build. `TLS_CA_REFRESH=0` turns the refresh off. `python scripts/trust_chain.py --config demo/oidc.json` refreshes it by hand.
70
+
71
+ ## Demo
72
+
73
+ `PORT` defaults to 3000. A loopback redirect and post-logout URL follow `PORT`. Any other host is left as written. An explicit `PUBLIC_URL` or `--public-url` replaces the origin and keeps the path. The default `http://localhost:3000` is not an explicit public URL.
74
+
75
+ ```sh
76
+ docker compose up --build
77
+ ```
78
+
79
+ `GET /healthz` returns `ok`. `GET /` is the page. `GET /login` starts `authorize` and redirects. `GET /callback` completes the code exchange. `POST /logout` signs the local session out. `GET /live-status` is JSON: `signedIn`, `subject`, the receiver state, and the Shared Signals events this process has accepted.
80
+
81
+ `docker-compose.yml` sets `network_mode: host`. The image sets `ENV PORT=3000`. The health check calls `http://127.0.0.1:${PORT}/healthz`. The running container's `PORT` wins over the shell's when `scripts/configure_iya_sts.py` registers the application.
82
+
83
+ Demo JSON uses `file:` and the configuration's own placeholders. It does not contain a generated secret or key.
@@ -0,0 +1,66 @@
1
+ # oidc-caep-risc
2
+
3
+ A confidential OpenID Connect relying party for a traditional Python application, and a CAEP and RISC receiver on one Shared Signals poll stream. Authorization Code with PKCE (S256) is required. Client authentication is `client_secret_basic`, `client_secret_post`, or `private_key_jwt`. A public client (`tokenEndpointAuthMethod` `"none"`) is refused. The only cryptographic dependency is `cryptography`.
4
+
5
+ JSON configuration keys are the same names the Node library `oidc-caep-risc-nodejs` reads. `signals` omitted or `true` keeps CAEP and RISC on. `signals: false` does not start the receiver, does not require transmitter metadata, and drops `ssf.read` and `ssf.manage` from the scope list after the default scopes are applied. `DEFAULT_SCOPES` still names those two scopes. The shipped demo leaves `signals` on (the key is omitted).
6
+
7
+ The license is MIT. `develop` and `main` are the same commit. A pull request merged into `main` publishes the package to PyPI after the test workflow succeeds on that commit. A direct push does not. There is no API token: PyPI Trusted Publishing trades the workflow's GitHub OIDC token for a short-lived upload token, from the `pypi` environment, and PyPI holds the publisher for this repository and `publish.yml`. A version already on PyPI stays there; the next release is a new `version` in `pyproject.toml` (and `VERSION`).
8
+
9
+ ## Usage
10
+
11
+ ```python
12
+ from oidc_caep_risc import RelyingParty
13
+
14
+ rp = RelyingParty.from_file("oidc.json")
15
+ request = rp.authorize()
16
+ # redirect the browser to request["url"]; keep state, nonce, and code_verifier
17
+ who = rp.complete(query, request)
18
+ ```
19
+
20
+ Tokens stay in this process. Keep the transaction until the browser returns.
21
+
22
+ ## Tests
23
+
24
+ ```bash
25
+ docker build --target test .
26
+ ```
27
+
28
+ The suite runs in a container.
29
+
30
+ The tests build an in-process stub provider and a stub transmitter. They do not start iya-sts.
31
+
32
+ `python3 scripts/live_iya_sts.py` is the live half. It starts the latest main branch of iya-sts in memory mode with persistence and clustering off, runs the install script in a scratch copy of this checkout (which writes the `tlsCa` chain), then builds this demo image from it, checks the image carries the chain, starts the demo with no CA mount and no refresh, signs in, and requires a credential-change event and a session-revoked event on `GET /live-status`. The second event ends the local session. It binds two high ports, never 3000 or 8081. The nightly workflow runs the unit suite and this script once every 24 hours.
33
+
34
+ ## Layout
35
+
36
+ | Path | What it is |
37
+ |---|---|
38
+ | `src/oidc_caep_risc` | The library. `relying_party.py`, `client_auth.py`, `id_token.py`, `pkce.py`, `discovery.py`, `config.py`, `ssf/`. |
39
+ | `tests` | pytest ports of the Node relying-party and Shared Signals tests. |
40
+ | `demo` | `demo/app.py`, a stdlib `http.server` application. Config is `demo/oidc.json`. |
41
+ | `scripts/configure_iya_sts.py` | Registers the confidential client on iya-sts and creates the test user `oidc-demo-user`. A generated client secret goes to the file `secretFile` names, and a generated password to `demo/generated/oidc-demo-user.password`, each mode 0600; the script prints neither. Loopback redirects follow the running container's `PORT`, then the shell `PORT`. |
42
+ | `Dockerfile`, `docker-compose.yml` | The demo image. Compose uses host networking. |
43
+
44
+ ## The iya-sts trust chain (`tlsCa`)
45
+
46
+ `tlsCa` in `demo/oidc.json` names the iya-sts TLS listener CA trust chain the library trusts. The default is `generated/idp-ca.pem`, relative to `demo/`. The value may also be `file:<path>`, `env:<NAME>`, or the PEM itself. The library adds every certificate in it to the default trust store.
47
+
48
+ `scripts/configure_iya_sts.py`, the install script, writes that file. It saves the whole chain iya-sts publishes at `GET /tls/server-certificate` (leaf, TLS Issuing CA, Intermediate, Root), or the chain from the TLS handshake when that path does not answer. It prints the path, the trust anchor's subject and its SHA-256 fingerprint. Compare the anchor with the Root CA on iya-sts's PKI page.
49
+
50
+ The demo image build copies that file into the image at `/app/demo/generated/idp-ca.pem`, so the image trusts that iya-sts with no bind mount and no fetch. Run the install script before you build the image. Rebuild the image after running the install script against a different iya-sts. `.dockerignore` admits only that file from `demo/generated`. The client secret stays out of the image. A build with no chain file yet (a fresh clone, CI) still succeeds; the image then has no `tlsCa` file.
51
+
52
+ The container entrypoint also refreshes the file from the provider at start, because a development iya-sts makes a new CA when it restarts. A failed refresh keeps the file from the build. `TLS_CA_REFRESH=0` turns the refresh off. `python scripts/trust_chain.py --config demo/oidc.json` refreshes it by hand.
53
+
54
+ ## Demo
55
+
56
+ `PORT` defaults to 3000. A loopback redirect and post-logout URL follow `PORT`. Any other host is left as written. An explicit `PUBLIC_URL` or `--public-url` replaces the origin and keeps the path. The default `http://localhost:3000` is not an explicit public URL.
57
+
58
+ ```sh
59
+ docker compose up --build
60
+ ```
61
+
62
+ `GET /healthz` returns `ok`. `GET /` is the page. `GET /login` starts `authorize` and redirects. `GET /callback` completes the code exchange. `POST /logout` signs the local session out. `GET /live-status` is JSON: `signedIn`, `subject`, the receiver state, and the Shared Signals events this process has accepted.
63
+
64
+ `docker-compose.yml` sets `network_mode: host`. The image sets `ENV PORT=3000`. The health check calls `http://127.0.0.1:${PORT}/healthz`. The running container's `PORT` wins over the shell's when `scripts/configure_iya_sts.py` registers the application.
65
+
66
+ Demo JSON uses `file:` and the configuration's own placeholders. It does not contain a generated secret or key.
@@ -0,0 +1,33 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ [project]
4
+ name = "oidc-caep-risc"
5
+ version = "0.1.0"
6
+ description = "Server-side OpenID Connect confidential client: Authorization Code with PKCE, client authentication, and a CAEP and RISC Shared Signals receiver."
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Iya CyberSecurity Solutions, LLC" }]
12
+ dependencies = ["cryptography"]
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest"]
16
+
17
+ [project.scripts]
18
+ oidc-caep-risc = "oidc_caep_risc.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=77"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/rcbj/oidc-caep-risc-python"
32
+ Repository = "https://github.com/rcbj/oidc-caep-risc-python"
33
+ Issues = "https://github.com/rcbj/oidc-caep-risc-python/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,59 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """A server-side OpenID Connect confidential client, and one CAEP/RISC poll stream.
4
+
5
+ rp = RelyingParty.from_file("oidc.json")
6
+ request = rp.authorize()
7
+ who = rp.complete(query, request)
8
+ """
9
+
10
+ from .config import read_config_file, resolve_config
11
+ from .discovery import (
12
+ ASYMMETRIC_JWS_ALGS,
13
+ DEFAULT_SCOPES,
14
+ discover_provider,
15
+ issuer_from_metadata_url,
16
+ validate_provider_metadata,
17
+ well_known_urls,
18
+ )
19
+ from .errors import OidcError
20
+ from .id_token import hash_claim, validate_id_token
21
+ from .pkce import code_challenge_s256, random_token
22
+ from .relying_party import RelyingParty
23
+ from .ssf import (
24
+ CAEP_EVENTS,
25
+ DEFAULT_ENDING_EVENTS,
26
+ DEFAULT_EVENTS_REQUESTED,
27
+ EVENT,
28
+ RISC_EVENTS,
29
+ MemorySessionIndex,
30
+ SignalsReceiver,
31
+ subject_of,
32
+ tracked_from_login,
33
+ )
34
+
35
+ __all__ = [
36
+ "ASYMMETRIC_JWS_ALGS",
37
+ "CAEP_EVENTS",
38
+ "DEFAULT_ENDING_EVENTS",
39
+ "DEFAULT_EVENTS_REQUESTED",
40
+ "DEFAULT_SCOPES",
41
+ "EVENT",
42
+ "RISC_EVENTS",
43
+ "MemorySessionIndex",
44
+ "OidcError",
45
+ "RelyingParty",
46
+ "SignalsReceiver",
47
+ "code_challenge_s256",
48
+ "discover_provider",
49
+ "hash_claim",
50
+ "issuer_from_metadata_url",
51
+ "random_token",
52
+ "read_config_file",
53
+ "resolve_config",
54
+ "subject_of",
55
+ "tracked_from_login",
56
+ "validate_id_token",
57
+ "validate_provider_metadata",
58
+ "well_known_urls",
59
+ ]
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Base64url without padding, the form JOSE and PKCE use."""
4
+
5
+ import base64
6
+ import re
7
+
8
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]*$")
9
+
10
+
11
+ def b64url_encode(data):
12
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
13
+
14
+
15
+ def b64url_decode(value, what="value"):
16
+ if not isinstance(value, str) or not _B64URL.fullmatch(value):
17
+ raise ValueError(f"{what} is not base64url")
18
+ pad = "=" * ((4 - len(value) % 4) % 4)
19
+ return base64.urlsafe_b64decode(value + pad)
@@ -0,0 +1,79 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """The back channel. TLS is always verified; ca is added to the default trust store."""
4
+
5
+ import http.client
6
+ import socket
7
+ import ssl
8
+ from urllib.parse import urlsplit
9
+
10
+ from .errors import OidcError
11
+
12
+ MAX_BYTES = 2 * 1024 * 1024
13
+ TIMEOUT_S = 15
14
+
15
+
16
+ def _ssl_context(tls):
17
+ tls = tls or {}
18
+ context = ssl.create_default_context()
19
+ ca = tls.get("ca")
20
+ if ca:
21
+ context.load_verify_locations(cadata=ca)
22
+ cert = tls.get("cert")
23
+ key = tls.get("key")
24
+ if cert and key:
25
+ # load_cert_chain wants paths. PEM strings go through temporary files only if needed;
26
+ # SSLContext has no string API, so use a memory BIO via cadata is not for keys.
27
+ import os
28
+ import tempfile
29
+ cert_file = tempfile.NamedTemporaryFile("w", delete=False)
30
+ key_file = tempfile.NamedTemporaryFile("w", delete=False)
31
+ try:
32
+ cert_file.write(cert)
33
+ cert_file.close()
34
+ key_file.write(key)
35
+ key_file.close()
36
+ context.load_cert_chain(cert_file.name, key_file.name)
37
+ finally:
38
+ os.unlink(cert_file.name)
39
+ os.unlink(key_file.name)
40
+ return context
41
+
42
+
43
+ def send(url, method="GET", headers=None, body=None, tls=None, code="protocol"):
44
+ """One HTTP(S) request. Redirects are not followed. The body is capped."""
45
+ parts = urlsplit(url)
46
+ if parts.scheme not in ("http", "https"):
47
+ raise OidcError(code, f"{url} is not an http(s) URL")
48
+ if not parts.hostname:
49
+ raise OidcError(code, f"{url} is not an http(s) URL")
50
+ port = parts.port or (443 if parts.scheme == "https" else 80)
51
+ path = parts.path or "/"
52
+ if parts.query:
53
+ path = f"{path}?{parts.query}"
54
+ payload = None if body is None else (body.encode("utf-8") if isinstance(body, str) else body)
55
+ hdrs = {str(k): str(v) for k, v in (headers or {}).items()}
56
+ if payload is not None:
57
+ hdrs["content-length"] = str(len(payload))
58
+ connection = None
59
+ try:
60
+ if parts.scheme == "https":
61
+ connection = http.client.HTTPSConnection(parts.hostname, port, timeout=TIMEOUT_S, context=_ssl_context(tls))
62
+ else:
63
+ connection = http.client.HTTPConnection(parts.hostname, port, timeout=TIMEOUT_S)
64
+ connection.request(method, path, body=payload, headers=hdrs)
65
+ response = connection.getresponse()
66
+ raw = response.read(MAX_BYTES + 1)
67
+ if len(raw) > MAX_BYTES:
68
+ raise OidcError(code, f"{url} answered more than {MAX_BYTES} bytes")
69
+ content_type = response.getheader("content-type") or ""
70
+ return {"status": response.status, "content_type": content_type, "body": raw.decode("utf-8", "replace")}
71
+ except OidcError:
72
+ raise
73
+ except socket.timeout as err:
74
+ raise OidcError(code, f"{url} did not answer within {TIMEOUT_S} s", cause=err) from err
75
+ except Exception as err:
76
+ raise OidcError(code, f"{url}: {err}", cause=err) from err
77
+ finally:
78
+ if connection is not None:
79
+ connection.close()
@@ -0,0 +1,56 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Reads a configuration file and says whether it names a confidential client. It does not print secrets."""
4
+
5
+ import sys
6
+
7
+ from .config import read_config_file
8
+ from .errors import OidcError
9
+
10
+
11
+ def usage():
12
+ return """oidc-caep-risc — a confidential OpenID Connect client
13
+
14
+ Usage
15
+ oidc-caep-risc check --config <file> read the file and print the client, without its secret
16
+ oidc-caep-risc help
17
+
18
+ The library is RelyingParty, imported from oidc_caep_risc."""
19
+
20
+
21
+ def main(argv=None):
22
+ argv = list(sys.argv[1:] if argv is None else argv)
23
+ command = argv[0] if argv else "help"
24
+ if command in ("help", "--help", "-h"):
25
+ print(usage())
26
+ return 0
27
+ if command != "check":
28
+ print(f"unknown command {command}\n{usage()}", file=sys.stderr)
29
+ return 1
30
+ try:
31
+ at = argv.index("--config")
32
+ except ValueError:
33
+ at = -1
34
+ file = argv[at + 1] if at >= 0 and at + 1 < len(argv) else None
35
+ if not file:
36
+ print(f"check needs --config <file>\n{usage()}", file=sys.stderr)
37
+ return 1
38
+ try:
39
+ config = read_config_file(file)
40
+ except OidcError as err:
41
+ print(err, file=sys.stderr)
42
+ return 1
43
+ metadata = config.get("metadataUrl") or (config.get("metadata") or {}).get("issuer") or "(file)"
44
+ print(f"client_id: {config['clientId']}")
45
+ print(f"auth: {config['tokenEndpointAuthMethod']}")
46
+ print(f"redirect: {config['redirectUri']}")
47
+ print(f"scopes: {config['scopes']}")
48
+ print(f"metadata: {metadata}")
49
+ print(f"secret: {'present' if config.get('clientSecret') else 'absent'}")
50
+ print(f"tls ca: {'present' if config.get('tlsCa') else 'absent'}")
51
+ print(f"ssf: {(config.get('ssf') or {}).get('transmitter') or 'not configured'}")
52
+ return 0
53
+
54
+
55
+ if __name__ == "__main__":
56
+ sys.exit(main())
@@ -0,0 +1,59 @@
1
+ # SPDX-FileCopyrightText: 2026 Iya CyberSecurity Solutions, LLC
2
+ # SPDX-License-Identifier: MIT
3
+ """Client authentication on a token or pushed-authorization request."""
4
+
5
+ import base64
6
+ import json
7
+ import time
8
+ import uuid
9
+ from urllib.parse import quote
10
+
11
+ from .b64 import b64url_encode
12
+ from .errors import OidcError
13
+ from .keys import load_private_key, sign_jws
14
+
15
+
16
+ def form_encode(value):
17
+ """application/x-www-form-urlencoded, the encoding of a Basic client id and secret (RFC 6749)."""
18
+ return quote(str(value), safe="-_.!~*'()").replace("%20", "+")
19
+
20
+
21
+ def load_signing_key(pem, alg):
22
+ return load_private_key(pem, alg)
23
+
24
+
25
+ def _compact(header, payload, key, alg):
26
+ signing_input = (
27
+ b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
28
+ + "."
29
+ + b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
30
+ )
31
+ signature = sign_jws(key, alg, signing_input.encode("ascii"))
32
+ return signing_input + "." + b64url_encode(signature)
33
+
34
+
35
+ def apply_client_auth(config, key, body, headers, audience):
36
+ """Adds this client's authentication. A client assertion's audience is the issuer."""
37
+ method = config["tokenEndpointAuthMethod"]
38
+ client_id = config["clientId"]
39
+ if method == "client_secret_basic":
40
+ raw = f"{form_encode(client_id)}:{form_encode(config.get('clientSecret') or '')}"
41
+ headers["authorization"] = "Basic " + base64.b64encode(raw.encode("utf-8")).decode("ascii")
42
+ return
43
+ if method == "client_secret_post":
44
+ body["client_id"] = client_id
45
+ body["client_secret"] = config.get("clientSecret") or ""
46
+ return
47
+ if key is None:
48
+ raise OidcError("config", "private_key_jwt has no signing key")
49
+ now = int(time.time())
50
+ alg = config.get("privateKeyAlg") or "RS256"
51
+ assertion = _compact(
52
+ {"alg": alg, "typ": "JWT"},
53
+ {"iss": client_id, "sub": client_id, "aud": audience, "jti": str(uuid.uuid4()), "iat": now, "exp": now + 60},
54
+ key,
55
+ alg,
56
+ )
57
+ body["client_id"] = client_id
58
+ body["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
59
+ body["client_assertion"] = assertion