rengo-cli 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.
- rengo_cli-0.1.0/.gitignore +6 -0
- rengo_cli-0.1.0/PKG-INFO +10 -0
- rengo_cli-0.1.0/pyproject.toml +56 -0
- rengo_cli-0.1.0/src/rengo_cli/__init__.py +1 -0
- rengo_cli-0.1.0/src/rengo_cli/auth.py +133 -0
- rengo_cli-0.1.0/src/rengo_cli/autologin.py +105 -0
- rengo_cli-0.1.0/src/rengo_cli/config.py +208 -0
- rengo_cli-0.1.0/src/rengo_cli/doctor.py +185 -0
- rengo_cli-0.1.0/src/rengo_cli/errors.py +18 -0
- rengo_cli-0.1.0/src/rengo_cli/keyring_backend.py +55 -0
- rengo_cli-0.1.0/src/rengo_cli/main.py +102 -0
- rengo_cli-0.1.0/src/rengo_cli/npm.py +213 -0
- rengo_cli-0.1.0/src/rengo_cli/oauth.py +202 -0
- rengo_cli-0.1.0/src/rengo_cli/storage.py +216 -0
- rengo_cli-0.1.0/uv.lock +793 -0
rengo_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rengo-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public Rengo developer authentication and package credential helper
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: cryptography<51,>=44
|
|
7
|
+
Requires-Dist: httpx<0.29,>=0.28
|
|
8
|
+
Requires-Dist: keyring==25.7.0
|
|
9
|
+
Requires-Dist: platformdirs<5,>=4
|
|
10
|
+
Requires-Dist: portalocker<4,>=3
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rengo-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Public Rengo developer authentication and package credential helper"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"cryptography>=44,<51",
|
|
12
|
+
"httpx>=0.28,<0.29",
|
|
13
|
+
"keyring==25.7.0",
|
|
14
|
+
"platformdirs>=4,<5",
|
|
15
|
+
"portalocker>=3,<4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
rengo-auth = "rengo_cli.main:main"
|
|
20
|
+
# pnpm tokenHelper launcher; other gateways reach it through per-origin links
|
|
21
|
+
# (rengo_cli.npm).
|
|
22
|
+
rengo-pnpm-token = "rengo_cli.npm:main"
|
|
23
|
+
# uv's subprocess keyring provider runs whatever `keyring` is on PATH, and a
|
|
24
|
+
# dependency's executables are not linked by `uv tool install`, so the CLI
|
|
25
|
+
# ships the one whose environment carries the Rengo backend. Other services
|
|
26
|
+
# still reach the OS store through keyring's chainer.
|
|
27
|
+
keyring = "keyring.cli:main"
|
|
28
|
+
|
|
29
|
+
[project.entry-points."keyring.backends"]
|
|
30
|
+
rengo = "rengo_cli.keyring_backend"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest>=8,<10", "ruff>=0.12", "mypy>=1.15"]
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/rengo_cli"]
|
|
37
|
+
|
|
38
|
+
# The public sdist carries the code and what builds it, not the test suite.
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
exclude = ["tests"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py311"
|
|
44
|
+
line-length = 100
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
python_version = "3.11"
|
|
51
|
+
strict = true
|
|
52
|
+
ignore_missing_imports = true
|
|
53
|
+
files = ["src/rengo_cli"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rengo package credentials for uv and pnpm."""
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .config import OAuthConfig, login_hint
|
|
7
|
+
from .errors import AuthError, ConnectionUnavailable, LoginRequired, OAuthRejected
|
|
8
|
+
from .oauth import Provider, authorize, token_bundle
|
|
9
|
+
from .storage import Store
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Auth:
|
|
13
|
+
def __init__(self, store: Store, provider: Provider | None = None):
|
|
14
|
+
self.store = store
|
|
15
|
+
self.provider = provider or Provider()
|
|
16
|
+
|
|
17
|
+
def _session(self) -> dict[str, Any]:
|
|
18
|
+
value = self.store.read()
|
|
19
|
+
if not value or value.get("logged_out"):
|
|
20
|
+
raise LoginRequired(f"No Rengo login. Run {login_hint(self.store.origin)}.")
|
|
21
|
+
if value.get("pending"):
|
|
22
|
+
raise LoginRequired(
|
|
23
|
+
f"Previous token exchange is uncertain; run {login_hint(self.store.origin)} again."
|
|
24
|
+
)
|
|
25
|
+
try:
|
|
26
|
+
for field in ("issued_at", "expires_at"):
|
|
27
|
+
if not isinstance(value[field], (int, float)) or not math.isfinite(value[field]):
|
|
28
|
+
raise ValueError
|
|
29
|
+
if not 120 < value["expires_at"] - value["issued_at"] <= 86400:
|
|
30
|
+
raise ValueError
|
|
31
|
+
for field in ("access_token", "refresh_token"):
|
|
32
|
+
if not isinstance(value[field], str) or not value[field]:
|
|
33
|
+
raise ValueError
|
|
34
|
+
OAuthConfig.parse(value["oauth"], self.store.origin)
|
|
35
|
+
except (KeyError, TypeError, ValueError):
|
|
36
|
+
raise LoginRequired(
|
|
37
|
+
f"Invalid credential state; run {login_hint(self.store.origin)} again."
|
|
38
|
+
) from None
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
def access_token(self) -> str:
|
|
42
|
+
with self.store.lock():
|
|
43
|
+
value = self._session()
|
|
44
|
+
now = time.time()
|
|
45
|
+
if value["expires_at"] > now + 120:
|
|
46
|
+
return str(value["access_token"])
|
|
47
|
+
config = OAuthConfig.parse(value["oauth"], self.store.origin)
|
|
48
|
+
# Commit before sending: process death and uncertain network responses must
|
|
49
|
+
# never cause a successor process to replay a possibly consumed refresh token.
|
|
50
|
+
# The provider rotates refresh tokens and detects reuse: replaying a consumed one
|
|
51
|
+
# revokes the whole grant, live successor included, forcing a browser login.
|
|
52
|
+
value["pending"] = True
|
|
53
|
+
self.store.write(value, value["storage"])
|
|
54
|
+
try:
|
|
55
|
+
raw = self.provider.exchange(
|
|
56
|
+
config, {"grant_type": "refresh_token", "refresh_token": value["refresh_token"]}
|
|
57
|
+
)
|
|
58
|
+
except ConnectionUnavailable:
|
|
59
|
+
value["pending"] = False
|
|
60
|
+
self.store.write(value, value["storage"])
|
|
61
|
+
raise
|
|
62
|
+
except OAuthRejected as error:
|
|
63
|
+
# The service answered definitively, so no unknown outcome can have
|
|
64
|
+
# consumed this refresh token. A definitive 4xx on a refresh_token grant
|
|
65
|
+
# means the stored grant cannot be used again, whatever the provider
|
|
66
|
+
# names the error: some answer invalid_request, not invalid_grant.
|
|
67
|
+
self.store.clear()
|
|
68
|
+
detail = f" ({error.code})" if error.code else ""
|
|
69
|
+
raise LoginRequired(
|
|
70
|
+
f"Rengo login is no longer valid{detail}; "
|
|
71
|
+
f"run {login_hint(self.store.origin)} again."
|
|
72
|
+
) from None
|
|
73
|
+
successor = token_bundle(raw, config, now)
|
|
74
|
+
self.store.write(successor, value["storage"])
|
|
75
|
+
return str(successor["access_token"])
|
|
76
|
+
|
|
77
|
+
def login(
|
|
78
|
+
self,
|
|
79
|
+
present: Callable[[str], None],
|
|
80
|
+
*,
|
|
81
|
+
storage: str = "native",
|
|
82
|
+
timeout: float = 180,
|
|
83
|
+
port: int = 0,
|
|
84
|
+
) -> None:
|
|
85
|
+
# Prove storage is available before the browser creates a new grant.
|
|
86
|
+
with self.store.lock():
|
|
87
|
+
self.store.prepare(storage)
|
|
88
|
+
config = self.provider.discover(self.store.origin)
|
|
89
|
+
code, verifier, redirect = authorize(config, present, timeout=timeout, port=port)
|
|
90
|
+
# Holding the same lock through exchange and commit makes final mutations linearizable.
|
|
91
|
+
with self.store.lock():
|
|
92
|
+
issued_at = time.time()
|
|
93
|
+
raw = self.provider.exchange(
|
|
94
|
+
config,
|
|
95
|
+
{
|
|
96
|
+
"grant_type": "authorization_code",
|
|
97
|
+
"code": code,
|
|
98
|
+
"code_verifier": verifier,
|
|
99
|
+
"redirect_uri": redirect,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
bundle = token_bundle(raw, config, issued_at)
|
|
103
|
+
self.store.write(bundle, storage)
|
|
104
|
+
|
|
105
|
+
def logout(self) -> bool:
|
|
106
|
+
with self.store.lock():
|
|
107
|
+
revoked = True
|
|
108
|
+
try:
|
|
109
|
+
value = self.store.read()
|
|
110
|
+
if value and not value.get("logged_out"):
|
|
111
|
+
config = OAuthConfig.parse(value["oauth"], self.store.origin)
|
|
112
|
+
self.provider.revoke(config, value["refresh_token"])
|
|
113
|
+
revoked = not value.get("pending", False)
|
|
114
|
+
except ConnectionUnavailable:
|
|
115
|
+
# Clearing here would destroy the only copy of an unrevoked refresh token.
|
|
116
|
+
raise AuthError(
|
|
117
|
+
"Cannot reach the OAuth service to revoke this login; retry when online."
|
|
118
|
+
) from None
|
|
119
|
+
except (AuthError, KeyError):
|
|
120
|
+
revoked = False
|
|
121
|
+
self.store.clear()
|
|
122
|
+
return revoked
|
|
123
|
+
|
|
124
|
+
def status(self) -> dict[str, Any]:
|
|
125
|
+
with self.store.lock():
|
|
126
|
+
value = self._session()
|
|
127
|
+
return {
|
|
128
|
+
"origin": self.store.origin,
|
|
129
|
+
"environment": value["oauth"]["environment"],
|
|
130
|
+
"storage": value["storage"],
|
|
131
|
+
"access_expires_at": value["expires_at"],
|
|
132
|
+
"refresh_needed": value["expires_at"] <= time.time() + 120,
|
|
133
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The browser login a credential lookup starts itself when no usable grant is stored.
|
|
2
|
+
|
|
3
|
+
uv (through the keyring backend) and pnpm (through the token helper) ask for a
|
|
4
|
+
credential as subprocesses whose stdout they capture, so everything said to the
|
|
5
|
+
person goes to the controlling terminal instead. With no terminal, in CI, or
|
|
6
|
+
with RENGO_AUTH_NO_AUTO_LOGIN set, the lookup fails exactly as it would without
|
|
7
|
+
this module, and the error names the login command.
|
|
8
|
+
|
|
9
|
+
Only `LoginRequired` starts a login: an offline refresh, a busy lock or a locked
|
|
10
|
+
OS store is not fixed by a new grant, and a login would throw away a refresh
|
|
11
|
+
token that still works.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import webbrowser
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
|
|
21
|
+
from .auth import Auth
|
|
22
|
+
from .errors import AuthError, LoginRequired
|
|
23
|
+
from .storage import Store
|
|
24
|
+
|
|
25
|
+
OPT_OUT_ENV = "RENGO_AUTH_NO_AUTO_LOGIN"
|
|
26
|
+
LOGIN_TIMEOUT = 180.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def terminal() -> TextIO | None:
|
|
30
|
+
"""The person's terminal, or None when nobody is there to finish a login."""
|
|
31
|
+
if os.name == "nt" or os.environ.get("CI") or os.environ.get(OPT_OUT_ENV):
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return open("/dev/tty", "w")
|
|
35
|
+
except OSError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextlib.contextmanager
|
|
40
|
+
def stdout_to_stderr() -> Iterator[None]:
|
|
41
|
+
"""Keep a browser launcher's output off stdout, which pnpm takes as the header."""
|
|
42
|
+
sys.stdout.flush()
|
|
43
|
+
saved = os.dup(1)
|
|
44
|
+
try:
|
|
45
|
+
os.dup2(2, 1)
|
|
46
|
+
yield
|
|
47
|
+
finally:
|
|
48
|
+
sys.stdout.flush()
|
|
49
|
+
os.dup2(saved, 1)
|
|
50
|
+
os.close(saved)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def access_token(store: Store, *, configure_pnpm: bool) -> str:
|
|
54
|
+
"""The stored access token, logging in through the browser first if none is usable."""
|
|
55
|
+
try:
|
|
56
|
+
return Auth(store).access_token()
|
|
57
|
+
except LoginRequired:
|
|
58
|
+
tty = terminal()
|
|
59
|
+
if tty is None:
|
|
60
|
+
raise
|
|
61
|
+
with tty:
|
|
62
|
+
try:
|
|
63
|
+
return _login(store, tty, configure_pnpm=configure_pnpm)
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
raise LoginRequired("Rengo login cancelled.") from None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _login(store: Store, tty: TextIO, *, configure_pnpm: bool) -> str:
|
|
69
|
+
# uv and pnpm may look up several URLs at once; the first lookup logs in and
|
|
70
|
+
# the rest find its grant once the lock is theirs.
|
|
71
|
+
with store.login_lock(LOGIN_TIMEOUT + 30):
|
|
72
|
+
try:
|
|
73
|
+
return Auth(store).access_token()
|
|
74
|
+
except LoginRequired:
|
|
75
|
+
pass
|
|
76
|
+
print(
|
|
77
|
+
f"No usable Rengo login for {store.origin}; opening your browser to log in "
|
|
78
|
+
"(Ctrl-C cancels).",
|
|
79
|
+
file=tty,
|
|
80
|
+
flush=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def present(url: str) -> None:
|
|
84
|
+
# URL has PKCE challenge/state, never a token or authorization code.
|
|
85
|
+
with stdout_to_stderr():
|
|
86
|
+
opened = webbrowser.open(url)
|
|
87
|
+
if not opened:
|
|
88
|
+
print("Open this authorization URL in your browser:\n" + url, file=tty, flush=True)
|
|
89
|
+
|
|
90
|
+
auth = Auth(store)
|
|
91
|
+
auth.login(present, timeout=LOGIN_TIMEOUT)
|
|
92
|
+
print(f"Logged in to Rengo ({store.origin}).", file=tty, flush=True)
|
|
93
|
+
if configure_pnpm:
|
|
94
|
+
from . import npm
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
path = npm.configure(store.origin)
|
|
98
|
+
print(
|
|
99
|
+
f"pnpm: {path} routes {npm.registry_url(store.origin)} through the CLI.",
|
|
100
|
+
file=tty,
|
|
101
|
+
flush=True,
|
|
102
|
+
)
|
|
103
|
+
except AuthError as error:
|
|
104
|
+
print(f"pnpm: not configured. {error}", file=tty, flush=True)
|
|
105
|
+
return auth.access_token()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from .errors import AuthError
|
|
8
|
+
|
|
9
|
+
# The gateway used outside an estate checkout. Inside one, the checkout's own
|
|
10
|
+
# package files name the gateway, and everything else about it is learned from
|
|
11
|
+
# that gateway's discovery document.
|
|
12
|
+
DEFAULT_GATEWAY = "https://packages.rengoai.com"
|
|
13
|
+
DEFAULT_AUTHORIZATION_ENDPOINT = "https://app.rengoai.com/oauth/authorize"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_origin(value: Any) -> str:
|
|
17
|
+
"""`https://host`, lower-cased: no userinfo, path, query, fragment or port but 443."""
|
|
18
|
+
if not isinstance(value, str) or any(c.isspace() for c in value) or "\\" in value:
|
|
19
|
+
raise AuthError("Expected a bare HTTPS origin such as https://host.")
|
|
20
|
+
parsed = urlsplit(value)
|
|
21
|
+
try:
|
|
22
|
+
port = parsed.port
|
|
23
|
+
except ValueError:
|
|
24
|
+
raise AuthError("Expected a bare HTTPS origin such as https://host.") from None
|
|
25
|
+
if (
|
|
26
|
+
parsed.scheme != "https"
|
|
27
|
+
or not parsed.hostname
|
|
28
|
+
or parsed.username is not None
|
|
29
|
+
or parsed.password is not None
|
|
30
|
+
or parsed.path not in ("", "/")
|
|
31
|
+
or parsed.query
|
|
32
|
+
or parsed.fragment
|
|
33
|
+
or port not in (None, 443)
|
|
34
|
+
):
|
|
35
|
+
raise AuthError("Expected a bare HTTPS origin such as https://host.")
|
|
36
|
+
return "https://" + parsed.hostname.lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def origin_host(origin: str) -> str:
|
|
40
|
+
return canonical_origin(origin).removeprefix("https://")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _declared_gateway(url: object, suffix: str) -> str | None:
|
|
44
|
+
"""The gateway origin a committed index or registry URL points at, or None."""
|
|
45
|
+
if not isinstance(url, str) or not url.endswith(suffix):
|
|
46
|
+
return None
|
|
47
|
+
parsed = urlsplit(url)
|
|
48
|
+
# uv index URLs carry the `__token__@` username the keyring answers for.
|
|
49
|
+
return "https://" + (parsed.hostname or "") + (f":{parsed.port}" if parsed.port else "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def project_gateway(start: Path | None = None) -> tuple[str, Path] | None:
|
|
53
|
+
"""The gateway the estate checkout around `start` declares, or None outside one.
|
|
54
|
+
|
|
55
|
+
An estate commits its gateway twice: the `[[tool.uv.index]]` URL in
|
|
56
|
+
pyproject.toml and the `@rengoai:registry=` line in .npmrc. Whichever
|
|
57
|
+
directory up from `start` first holds either file decides, so a command
|
|
58
|
+
run anywhere inside a checkout acts on that estate's gateway.
|
|
59
|
+
"""
|
|
60
|
+
for directory in [start or Path.cwd(), *(start or Path.cwd()).parents]:
|
|
61
|
+
pyproject, npmrc = directory / "pyproject.toml", directory / ".npmrc"
|
|
62
|
+
if not pyproject.is_file() and not npmrc.is_file():
|
|
63
|
+
continue
|
|
64
|
+
declared: set[str | None] = set()
|
|
65
|
+
if pyproject.is_file():
|
|
66
|
+
try:
|
|
67
|
+
indexes = tomllib.loads(pyproject.read_text())["tool"]["uv"]["index"]
|
|
68
|
+
except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError):
|
|
69
|
+
indexes = []
|
|
70
|
+
for index in indexes if isinstance(indexes, list) else []:
|
|
71
|
+
if isinstance(index, dict):
|
|
72
|
+
declared.add(_declared_gateway(index.get("url"), "/pypi/simple/"))
|
|
73
|
+
if npmrc.is_file():
|
|
74
|
+
for line in npmrc.read_text(errors="replace").splitlines():
|
|
75
|
+
if line.strip().startswith("@rengoai:registry="):
|
|
76
|
+
declared.add(_declared_gateway(line.split("=", 1)[1].strip(), "/npm/"))
|
|
77
|
+
declared.discard(None)
|
|
78
|
+
if not declared:
|
|
79
|
+
return None
|
|
80
|
+
if len(declared) > 1:
|
|
81
|
+
raise AuthError(f"{directory} declares more than one package gateway.")
|
|
82
|
+
return canonical_origin(declared.pop()), directory
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_gateway() -> tuple[str, str]:
|
|
87
|
+
"""The gateway a command acts on and where the choice came from.
|
|
88
|
+
|
|
89
|
+
Inside an estate checkout, the gateway that checkout declares, taken as
|
|
90
|
+
written; anywhere else, the default.
|
|
91
|
+
"""
|
|
92
|
+
declared = project_gateway()
|
|
93
|
+
if declared is None:
|
|
94
|
+
return DEFAULT_GATEWAY, "default"
|
|
95
|
+
return declared[0], f"project {declared[1]}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def login_hint(origin: str) -> str:
|
|
99
|
+
if origin == DEFAULT_GATEWAY:
|
|
100
|
+
return "rengo-auth login"
|
|
101
|
+
return f"rengo-auth login inside an estate checkout for {origin}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def service_origin(service: str) -> str | None:
|
|
105
|
+
"""The origin uv or keyring is asking about, or None if it is not an https origin.
|
|
106
|
+
|
|
107
|
+
uv passes either the bare hostname or the full index URL. Only the https
|
|
108
|
+
authority on port 443 with no userinfo counts; `https://host:8443/` must
|
|
109
|
+
not inherit the credentials of `https://host/`.
|
|
110
|
+
"""
|
|
111
|
+
candidate = service if "://" in service else "https://" + service
|
|
112
|
+
parsed = urlsplit(candidate)
|
|
113
|
+
if parsed.scheme != "https" or not parsed.hostname or parsed.query or parsed.fragment:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return canonical_origin("https://" + parsed.netloc)
|
|
117
|
+
except AuthError:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def https_url(value: Any) -> str:
|
|
122
|
+
if not isinstance(value, str) or any(c.isspace() for c in value):
|
|
123
|
+
raise AuthError("Invalid public OAuth configuration.")
|
|
124
|
+
parsed = urlsplit(value)
|
|
125
|
+
if (
|
|
126
|
+
parsed.scheme != "https"
|
|
127
|
+
or not parsed.hostname
|
|
128
|
+
or parsed.username
|
|
129
|
+
or parsed.password
|
|
130
|
+
or parsed.port not in (None, 443)
|
|
131
|
+
or parsed.query
|
|
132
|
+
or parsed.fragment
|
|
133
|
+
or "\\" in value
|
|
134
|
+
):
|
|
135
|
+
raise AuthError("Invalid public OAuth configuration.")
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class OAuthConfig:
|
|
141
|
+
version: int
|
|
142
|
+
environment: str
|
|
143
|
+
registry_url: str
|
|
144
|
+
client_id: str
|
|
145
|
+
issuer: str
|
|
146
|
+
authorization_endpoint: str
|
|
147
|
+
token_endpoint: str
|
|
148
|
+
revocation_endpoint: str
|
|
149
|
+
scopes: list[str]
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def parse(cls, raw: Any, origin: str) -> "OAuthConfig":
|
|
153
|
+
"""Validate a discovery document for the gateway it describes.
|
|
154
|
+
|
|
155
|
+
`origin` is the gateway the document was fetched from, or the origin a
|
|
156
|
+
stored bundle was bound to at login: a refresh is never routed by
|
|
157
|
+
rediscovery.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
origin = canonical_origin(origin)
|
|
161
|
+
config = cls(**{name: raw[name] for name in cls.__dataclass_fields__})
|
|
162
|
+
if config.version != 1 or type(config.version) is not int:
|
|
163
|
+
raise ValueError
|
|
164
|
+
# A label for `status`; it decides nothing.
|
|
165
|
+
if not isinstance(config.environment, str) or not config.environment:
|
|
166
|
+
raise ValueError
|
|
167
|
+
# Both registries have one fixed shape under the origin; a gateway
|
|
168
|
+
# that advertises the npm one must agree.
|
|
169
|
+
if config.registry_url != origin + "/pypi/simple/":
|
|
170
|
+
raise ValueError
|
|
171
|
+
npm_registry = raw.get("npm_registry_url") if isinstance(raw, dict) else None
|
|
172
|
+
if npm_registry is not None and npm_registry != origin + "/npm/":
|
|
173
|
+
raise ValueError
|
|
174
|
+
# Where the browser is sent. The default gateway pins it; another
|
|
175
|
+
# gateway's document is trusted for the page it names.
|
|
176
|
+
authorization = https_url(config.authorization_endpoint)
|
|
177
|
+
if origin == DEFAULT_GATEWAY and authorization != DEFAULT_AUTHORIZATION_ENDPOINT:
|
|
178
|
+
raise ValueError
|
|
179
|
+
if urlsplit(authorization).path != "/oauth/authorize":
|
|
180
|
+
raise ValueError
|
|
181
|
+
if not isinstance(config.client_id, str) or not 1 <= len(config.client_id) <= 256:
|
|
182
|
+
raise ValueError
|
|
183
|
+
issuer = https_url(config.issuer)
|
|
184
|
+
if urlsplit(issuer).path not in ("", "/"):
|
|
185
|
+
raise ValueError
|
|
186
|
+
# The trusted gateway delegates its OAuth authority to this issuer.
|
|
187
|
+
# Persist this binding with the grant; never rediscover to route a refresh.
|
|
188
|
+
for endpoint, path in (
|
|
189
|
+
(config.token_endpoint, "/v1/oauth2/token"),
|
|
190
|
+
(config.revocation_endpoint, "/v1/oauth2/revoke"),
|
|
191
|
+
):
|
|
192
|
+
if https_url(endpoint) != issuer.rstrip("/") + path:
|
|
193
|
+
raise ValueError
|
|
194
|
+
if (
|
|
195
|
+
not isinstance(config.scopes, list)
|
|
196
|
+
or not all(
|
|
197
|
+
isinstance(s, str) and s and not any(c.isspace() for c in s)
|
|
198
|
+
for s in config.scopes
|
|
199
|
+
)
|
|
200
|
+
or not {"packages:read", "offline_access"}.issubset(config.scopes)
|
|
201
|
+
):
|
|
202
|
+
raise ValueError
|
|
203
|
+
return config
|
|
204
|
+
except (TypeError, KeyError, ValueError, AttributeError, AuthError):
|
|
205
|
+
raise AuthError("Invalid public OAuth configuration; contact Rengo support.") from None
|
|
206
|
+
|
|
207
|
+
def public_dict(self) -> dict[str, Any]:
|
|
208
|
+
return asdict(self)
|