mindin 0.14.0__py3-none-any.whl
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.
- mindin/__init__.py +3 -0
- mindin/auth/__init__.py +268 -0
- mindin/auth/cimd.py +229 -0
- mindin/auth/database.py +107 -0
- mindin/auth/device.py +272 -0
- mindin/auth/hosted.py +705 -0
- mindin/auth/hosted_access.py +204 -0
- mindin/auth/hosted_deletion.py +142 -0
- mindin/auth/hosted_history.py +184 -0
- mindin/auth/hosted_keys.py +367 -0
- mindin/auth/hosted_layout.py +53 -0
- mindin/auth/hosted_members.py +126 -0
- mindin/auth/hosted_ready.py +76 -0
- mindin/auth/hosted_shared_pages.py +367 -0
- mindin/auth/hosted_snapshots.py +48 -0
- mindin/auth/hosted_store.py +900 -0
- mindin/auth/hosted_transfer.py +181 -0
- mindin/auth/hosted_vault_pages.py +543 -0
- mindin/auth/hosted_vaults.py +376 -0
- mindin/auth/hosted_web.py +427 -0
- mindin/auth/identities.py +299 -0
- mindin/auth/login.py +164 -0
- mindin/auth/login_email.py +140 -0
- mindin/auth/login_forwarded.py +125 -0
- mindin/auth/login_github.py +139 -0
- mindin/auth/login_oidc.py +90 -0
- mindin/auth/login_passkey.py +280 -0
- mindin/auth/pages.py +241 -0
- mindin/auth/prefix.py +25 -0
- mindin/auth/scopes.py +15 -0
- mindin/auth/upstream.py +171 -0
- mindin/backends/__init__.py +120 -0
- mindin/backends/local.py +143 -0
- mindin/backends/s3.py +177 -0
- mindin/backends/sealed.py +211 -0
- mindin/changelog.py +173 -0
- mindin/changes.py +182 -0
- mindin/cli.py +995 -0
- mindin/config.py +199 -0
- mindin/credentials.py +204 -0
- mindin/dav.py +419 -0
- mindin/embed_worker.py +410 -0
- mindin/embedding.py +403 -0
- mindin/extension_api.py +82 -0
- mindin/extensions.py +105 -0
- mindin/frontmatter.py +172 -0
- mindin/git.py +62 -0
- mindin/hooks.py +417 -0
- mindin/human.py +377 -0
- mindin/index.py +498 -0
- mindin/login.py +144 -0
- mindin/notes.py +897 -0
- mindin/places.py +281 -0
- mindin/presets.py +119 -0
- mindin/provision.py +146 -0
- mindin/proxy.py +128 -0
- mindin/remote.py +378 -0
- mindin/schema.py +598 -0
- mindin/search.py +428 -0
- mindin/server.py +544 -0
- mindin/shared.py +189 -0
- mindin/snapshot.py +147 -0
- mindin/stemming.py +77 -0
- mindin/tasks.py +260 -0
- mindin/templates/Areas.base +49 -0
- mindin/templates/Backlog.base +62 -0
- mindin/templates/Log.base +40 -0
- mindin/templates/Open tasks.base +64 -0
- mindin/templates/Resources.base +54 -0
- mindin/templates/__init__.py +0 -0
- mindin/templates/claude-md-snippet.md +22 -0
- mindin/templates/presets/custom/All notes.base +39 -0
- mindin/templates/presets/custom/schema.yml +25 -0
- mindin/templates/presets/journal/Backlog.base +62 -0
- mindin/templates/presets/journal/Days.base +29 -0
- mindin/templates/presets/journal/Log.base +40 -0
- mindin/templates/presets/journal/Open work.base +64 -0
- mindin/templates/presets/journal/Templates/Bases/Day.base +25 -0
- mindin/templates/presets/journal/Templates/Daily Note Template.md +10 -0
- mindin/templates/presets/journal/schema.yml +37 -0
- mindin/templates/presets/para/Areas.base +39 -0
- mindin/templates/presets/para/Backlog.base +62 -0
- mindin/templates/presets/para/Log.base +40 -0
- mindin/templates/presets/para/Projects.base +64 -0
- mindin/templates/presets/para/Resources.base +47 -0
- mindin/templates/presets/para/schema.yml +37 -0
- mindin/templates/presets/shared/Areas.base +49 -0
- mindin/templates/presets/shared/Resources.base +54 -0
- mindin/templates/presets/shared/schema.yml +35 -0
- mindin/templates/presets/simple/Backlog.base +62 -0
- mindin/templates/presets/simple/Categories.base +16 -0
- mindin/templates/presets/simple/Open work.base +64 -0
- mindin/templates/presets/simple/Recent.base +36 -0
- mindin/templates/presets/simple/Templates/Bases/Category.base +32 -0
- mindin/templates/presets/simple/Templates/Bases/Daily.base +25 -0
- mindin/templates/presets/simple/Templates/Category Template.md +5 -0
- mindin/templates/presets/simple/Templates/Daily Note Template.md +5 -0
- mindin/templates/presets/simple/Templates/Project Template.md +4 -0
- mindin/templates/presets/simple/schema.yml +29 -0
- mindin/templates/schema.yml +47 -0
- mindin/templates/welcome.md +41 -0
- mindin/terminal.py +110 -0
- mindin/vault.py +527 -0
- mindin/vectors.py +282 -0
- mindin/views.py +66 -0
- mindin-0.14.0.dist-info/METADATA +87 -0
- mindin-0.14.0.dist-info/RECORD +110 -0
- mindin-0.14.0.dist-info/WHEEL +4 -0
- mindin-0.14.0.dist-info/entry_points.txt +2 -0
- mindin-0.14.0.dist-info/licenses/LICENSE +21 -0
mindin/__init__.py
ADDED
mindin/auth/__init__.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import ipaddress
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from mindin.config import ConfigError, cache_dir, env, private_dir
|
|
12
|
+
|
|
13
|
+
Registration = Literal["invite", "open"]
|
|
14
|
+
SmtpSecurity = Literal["starttls", "ssl", "none"]
|
|
15
|
+
READ_SCOPE = "vault:read"
|
|
16
|
+
WRITE_SCOPE = "vault:write"
|
|
17
|
+
SCOPES = (READ_SCOPE, WRITE_SCOPE)
|
|
18
|
+
MACHINE_KEY_PURPOSE = "device"
|
|
19
|
+
DEFAULT_IDENTITY_HEADER = "X-Forwarded-Identity"
|
|
20
|
+
DEFAULT_SUBJECT_PREFIX = "users"
|
|
21
|
+
DEFAULT_OIDC_SCOPES = ("openid", "email", "profile")
|
|
22
|
+
SMTP_PORTS = {"starttls": 587, "ssl": 465, "none": 25}
|
|
23
|
+
|
|
24
|
+
NO_PUBLIC_URL = (
|
|
25
|
+
"mindin: an HTTP server needs VAULT_PUBLIC_URL, the https address clients reach it on "
|
|
26
|
+
"(for example https://vault.example.com); there is no default and never a fallback."
|
|
27
|
+
)
|
|
28
|
+
NO_SIGN_IN = (
|
|
29
|
+
"mindin: no way to sign in is configured. Set at least one of VAULT_SMTP_HOST (email links), "
|
|
30
|
+
"VAULT_GITHUB_CLIENT_ID (GitHub), VAULT_OIDC_ISSUER (an OpenID provider) or VAULT_IDENTITY_PUBLIC_KEY "
|
|
31
|
+
"(a trusted proxy); passkeys are added on the account page after a first sign-in."
|
|
32
|
+
)
|
|
33
|
+
BAD_IDENTITY_KEY = "mindin: VAULT_IDENTITY_PUBLIC_KEY is not a PEM public key: {reason}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SmtpConfig:
|
|
38
|
+
host: str
|
|
39
|
+
sender: str
|
|
40
|
+
port: int = 587
|
|
41
|
+
security: SmtpSecurity = "starttls"
|
|
42
|
+
username: str = ""
|
|
43
|
+
password: str = field(default="", repr=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class GithubConfig:
|
|
48
|
+
client_id: str
|
|
49
|
+
client_secret: str = field(repr=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class OidcConfig:
|
|
54
|
+
issuer: str
|
|
55
|
+
client_id: str
|
|
56
|
+
client_secret: str = field(repr=False)
|
|
57
|
+
scopes: tuple[str, ...] = DEFAULT_OIDC_SCOPES
|
|
58
|
+
name: str = "your organisation"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ForwardedConfig:
|
|
63
|
+
public_key: str
|
|
64
|
+
issuer: str
|
|
65
|
+
header: str = DEFAULT_IDENTITY_HEADER
|
|
66
|
+
typ: str = ""
|
|
67
|
+
write_claims: tuple[str, ...] = (WRITE_SCOPE,)
|
|
68
|
+
read_claims: tuple[str, ...] = (READ_SCOPE,)
|
|
69
|
+
name: str = "your company's sign-in"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class AuthConfig:
|
|
74
|
+
public_url: str = ""
|
|
75
|
+
issuer: str = ""
|
|
76
|
+
auth_dir: Path | None = None
|
|
77
|
+
subject_prefix: str = DEFAULT_SUBJECT_PREFIX
|
|
78
|
+
allowed_hosts: tuple[str, ...] = ()
|
|
79
|
+
registration: Registration = "invite"
|
|
80
|
+
passkeys: bool = True
|
|
81
|
+
stateless_http: bool = False
|
|
82
|
+
index_snapshots: bool = False
|
|
83
|
+
snapshot_minutes: float = 10.0
|
|
84
|
+
forwarded_allow_ips: tuple[str, ...] = ()
|
|
85
|
+
smtp: SmtpConfig | None = None
|
|
86
|
+
github: GithubConfig | None = None
|
|
87
|
+
oidc: OidcConfig | None = None
|
|
88
|
+
forwarded: ForwardedConfig | None = None
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def path_prefix(self) -> str:
|
|
92
|
+
return urlsplit(self.public_url).path.rstrip("/") if self.public_url else ""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def public_host(self) -> str:
|
|
96
|
+
url = self.public_url if "://" in self.public_url else f"//{self.public_url}"
|
|
97
|
+
return urlsplit(url).hostname or ""
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def public_origin(self) -> str:
|
|
101
|
+
parts = urlsplit(self.public_url)
|
|
102
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def resource_url(self) -> str:
|
|
106
|
+
return f"{self.public_url.rstrip('/')}/mcp"
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def dav_url(self) -> str:
|
|
110
|
+
return f"{self.public_url.rstrip('/')}/dav/"
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def oidc_redirect_uri(self) -> str:
|
|
114
|
+
return f"{self.public_url.rstrip('/')}/oidc/callback"
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def github_redirect_uri(self) -> str:
|
|
118
|
+
return f"{self.public_url.rstrip('/')}/signin/github/callback"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def auth_dir() -> Path:
|
|
122
|
+
raw = env("VAULT_AUTH_DIR")
|
|
123
|
+
return private_dir(Path(raw).expanduser() if raw else cache_dir() / "auth")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def public_key_pem(raw: str) -> str:
|
|
127
|
+
text = raw.strip()
|
|
128
|
+
if not text.startswith("-----BEGIN"):
|
|
129
|
+
try:
|
|
130
|
+
text = base64.b64decode(text, validate=True).decode("utf-8").strip()
|
|
131
|
+
except (binascii.Error, UnicodeDecodeError) as exc:
|
|
132
|
+
raise ConfigError(BAD_IDENTITY_KEY.format(reason="neither PEM nor base64 PEM")) from exc
|
|
133
|
+
from cryptography.hazmat.primitives import serialization
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
serialization.load_pem_public_key(text.encode("utf-8"))
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise ConfigError(BAD_IDENTITY_KEY.format(reason=exc)) from exc
|
|
139
|
+
return text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _text(name: str) -> str:
|
|
143
|
+
return (env(name) or "").strip()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _words(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
|
147
|
+
return tuple(_text(name).replace(",", " ").split()) or default
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _needs(name: str, because: str) -> str:
|
|
151
|
+
value = _text(name)
|
|
152
|
+
if not value:
|
|
153
|
+
raise ConfigError(f"mindin: {because} needs {name} as well")
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _minutes(name: str, default: float) -> float:
|
|
158
|
+
raw = _text(name)
|
|
159
|
+
try:
|
|
160
|
+
value = float(raw) if raw else default
|
|
161
|
+
except ValueError:
|
|
162
|
+
value = -1.0
|
|
163
|
+
if value < 0:
|
|
164
|
+
raise ConfigError(f"mindin: {name} must be a number of minutes, 0 or more")
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _choice(name: str, choices: tuple[str, ...], default: str) -> str:
|
|
169
|
+
value = _text(name).lower() or default
|
|
170
|
+
if value not in choices:
|
|
171
|
+
raise ConfigError(f"mindin: {name} must be one of {', '.join(choices)}")
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def proxy_addresses(name: str) -> tuple[str, ...]:
|
|
176
|
+
entries = _words(name, ())
|
|
177
|
+
if entries == ("*",):
|
|
178
|
+
return entries
|
|
179
|
+
for entry in entries:
|
|
180
|
+
try:
|
|
181
|
+
ipaddress.ip_network(entry, strict=False)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
raise ConfigError(f"mindin: {name} holds {entry!r}, which is no IP address or CIDR range") from exc
|
|
184
|
+
return entries
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def auth_config() -> AuthConfig:
|
|
188
|
+
public_url = _text("VAULT_PUBLIC_URL").rstrip("/")
|
|
189
|
+
if not public_url.startswith("https://") and not public_url.startswith("http://localhost"):
|
|
190
|
+
raise ConfigError(NO_PUBLIC_URL)
|
|
191
|
+
config = AuthConfig(
|
|
192
|
+
public_url=public_url,
|
|
193
|
+
issuer=public_url,
|
|
194
|
+
auth_dir=auth_dir(),
|
|
195
|
+
subject_prefix=(_text("VAULT_SUBJECT_PREFIX") or DEFAULT_SUBJECT_PREFIX).strip("/"),
|
|
196
|
+
allowed_hosts=_words("VAULT_ALLOWED_HOSTS", ()),
|
|
197
|
+
registration="open" if _choice("VAULT_REGISTRATION", ("invite", "open"), "invite") == "open" else "invite",
|
|
198
|
+
passkeys=_choice("VAULT_PASSKEYS", ("on", "off"), "on") == "on",
|
|
199
|
+
stateless_http=_choice("VAULT_HTTP_STATELESS", ("on", "off"), "off") == "on",
|
|
200
|
+
index_snapshots=_choice("VAULT_INDEX_SNAPSHOTS", ("on", "off"), "off") == "on",
|
|
201
|
+
snapshot_minutes=_minutes("VAULT_INDEX_SNAPSHOT_MINUTES", 10.0),
|
|
202
|
+
forwarded_allow_ips=proxy_addresses("VAULT_FORWARDED_ALLOW_IPS"),
|
|
203
|
+
smtp=smtp_config(),
|
|
204
|
+
github=github_config(),
|
|
205
|
+
oidc=oidc_config(),
|
|
206
|
+
forwarded=forwarded_config(),
|
|
207
|
+
)
|
|
208
|
+
if not (config.smtp or config.github or config.oidc or config.forwarded):
|
|
209
|
+
raise ConfigError(NO_SIGN_IN)
|
|
210
|
+
return config
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def smtp_config() -> SmtpConfig | None:
|
|
214
|
+
host = _text("VAULT_SMTP_HOST")
|
|
215
|
+
if not host:
|
|
216
|
+
return None
|
|
217
|
+
security = _choice("VAULT_SMTP_SECURITY", ("starttls", "ssl", "none"), "starttls")
|
|
218
|
+
port = _text("VAULT_SMTP_PORT") or str(SMTP_PORTS[security])
|
|
219
|
+
if not port.isdigit():
|
|
220
|
+
raise ConfigError("mindin: VAULT_SMTP_PORT must be a port number")
|
|
221
|
+
username, password = _text("VAULT_SMTP_USERNAME"), env("VAULT_SMTP_PASSWORD") or ""
|
|
222
|
+
if security == "none" and (username or password):
|
|
223
|
+
raise ConfigError(
|
|
224
|
+
"mindin: VAULT_SMTP_SECURITY=none would send the SMTP password in the clear; use starttls or ssl"
|
|
225
|
+
)
|
|
226
|
+
return SmtpConfig(
|
|
227
|
+
host=host,
|
|
228
|
+
sender=_needs("VAULT_SMTP_FROM", "VAULT_SMTP_HOST"),
|
|
229
|
+
port=int(port),
|
|
230
|
+
security="ssl" if security == "ssl" else "none" if security == "none" else "starttls",
|
|
231
|
+
username=username,
|
|
232
|
+
password=password,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def github_config() -> GithubConfig | None:
|
|
237
|
+
client_id = _text("VAULT_GITHUB_CLIENT_ID")
|
|
238
|
+
if not client_id:
|
|
239
|
+
return None
|
|
240
|
+
return GithubConfig(client_id=client_id, client_secret=_needs("VAULT_GITHUB_CLIENT_SECRET", "GitHub sign-in"))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def oidc_config() -> OidcConfig | None:
|
|
244
|
+
issuer = _text("VAULT_OIDC_ISSUER").rstrip("/")
|
|
245
|
+
if not issuer:
|
|
246
|
+
return None
|
|
247
|
+
return OidcConfig(
|
|
248
|
+
issuer=issuer,
|
|
249
|
+
client_id=_needs("VAULT_OIDC_CLIENT_ID", "VAULT_OIDC_ISSUER"),
|
|
250
|
+
client_secret=_needs("VAULT_OIDC_CLIENT_SECRET", "VAULT_OIDC_ISSUER"),
|
|
251
|
+
scopes=_words("VAULT_OIDC_SCOPES", DEFAULT_OIDC_SCOPES),
|
|
252
|
+
name=_text("VAULT_OIDC_NAME") or OidcConfig.name,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def forwarded_config() -> ForwardedConfig | None:
|
|
257
|
+
raw_key = _text("VAULT_IDENTITY_PUBLIC_KEY")
|
|
258
|
+
if not raw_key:
|
|
259
|
+
return None
|
|
260
|
+
return ForwardedConfig(
|
|
261
|
+
public_key=public_key_pem(raw_key),
|
|
262
|
+
issuer=_needs("VAULT_IDENTITY_ISSUER", "VAULT_IDENTITY_PUBLIC_KEY"),
|
|
263
|
+
header=_text("VAULT_IDENTITY_HEADER") or DEFAULT_IDENTITY_HEADER,
|
|
264
|
+
typ=_text("VAULT_IDENTITY_TYP"),
|
|
265
|
+
write_claims=_words("VAULT_IDENTITY_WRITE_CLAIMS", (WRITE_SCOPE,)),
|
|
266
|
+
read_claims=_words("VAULT_IDENTITY_READ_CLAIMS", (READ_SCOPE,)),
|
|
267
|
+
name=_text("VAULT_IDENTITY_NAME") or ForwardedConfig.name,
|
|
268
|
+
)
|
mindin/auth/cimd.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import httpx2
|
|
14
|
+
from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull
|
|
15
|
+
from pydantic import AnyUrl, ValidationError
|
|
16
|
+
|
|
17
|
+
from mindin.auth import SCOPES
|
|
18
|
+
|
|
19
|
+
MAX_DOCUMENT_BYTES = 5 * 1024
|
|
20
|
+
FETCH_TIMEOUT = 5.0
|
|
21
|
+
DEFAULT_CACHE_SECONDS = 3600
|
|
22
|
+
MAX_CACHE_SECONDS = 24 * 3600
|
|
23
|
+
FETCHES_PER_MINUTE = 30
|
|
24
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
25
|
+
ALLOWED_GRANTS = frozenset({"authorization_code", "refresh_token"})
|
|
26
|
+
MAX_AGE_RE = re.compile(r"max-age=(\d+)")
|
|
27
|
+
SCHEME_RE = re.compile(r"[a-z][a-z0-9+.-]*")
|
|
28
|
+
UNSAFE_SCHEMES = frozenset({"javascript", "data", "file", "vbscript", "blob", "about"})
|
|
29
|
+
|
|
30
|
+
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClientMetadataError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_metadata_url(client_id: str) -> bool:
|
|
38
|
+
return client_id.startswith("https://")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_loopback(url: str) -> bool:
|
|
42
|
+
parts = urlsplit(url)
|
|
43
|
+
return parts.scheme == "http" and (parts.hostname or "") in LOOPBACK_HOSTS
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def same_redirect(registered: str, requested: str) -> bool:
|
|
47
|
+
if registered == requested:
|
|
48
|
+
return True
|
|
49
|
+
if not (is_loopback(registered) and is_loopback(requested)):
|
|
50
|
+
return False
|
|
51
|
+
left, right = urlsplit(registered), urlsplit(requested)
|
|
52
|
+
return (left.hostname, left.path, left.query, left.fragment) == (
|
|
53
|
+
right.hostname,
|
|
54
|
+
right.path,
|
|
55
|
+
right.query,
|
|
56
|
+
right.fragment,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def allowed_redirect(uri: str) -> bool:
|
|
61
|
+
parts = urlsplit(uri)
|
|
62
|
+
if parts.scheme == "https":
|
|
63
|
+
return bool(parts.hostname)
|
|
64
|
+
if parts.scheme == "http":
|
|
65
|
+
return is_loopback(uri)
|
|
66
|
+
return bool(SCHEME_RE.fullmatch(parts.scheme)) and parts.scheme not in UNSAFE_SCHEMES
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HostedClient(OAuthClientInformationFull):
|
|
70
|
+
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
|
|
71
|
+
if redirect_uri is None:
|
|
72
|
+
return super().validate_redirect_uri(None)
|
|
73
|
+
requested = str(redirect_uri)
|
|
74
|
+
if any(same_redirect(str(registered), requested) for registered in self.redirect_uris or []):
|
|
75
|
+
return redirect_uri
|
|
76
|
+
raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_metadata_url(url: str) -> tuple[str, int]:
|
|
80
|
+
parts = urlsplit(url)
|
|
81
|
+
if parts.scheme != "https" or not parts.hostname:
|
|
82
|
+
raise ClientMetadataError("a client id metadata URL must be https with a host")
|
|
83
|
+
if parts.username or parts.password or parts.fragment:
|
|
84
|
+
raise ClientMetadataError("a client id metadata URL may carry neither credentials nor a fragment")
|
|
85
|
+
if parts.path in ("", "/") or any(segment in (".", "..") for segment in parts.path.split("/")):
|
|
86
|
+
raise ClientMetadataError("a client id metadata URL needs a path without dot segments")
|
|
87
|
+
return parts.hostname, parts.port or 443
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def public_address(address: str) -> bool:
|
|
91
|
+
ip = ipaddress.ip_address(address)
|
|
92
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
93
|
+
ip = ip.ipv4_mapped
|
|
94
|
+
return ip.is_global and not ip.is_multicast
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def system_resolver(host: str, port: int) -> list[str]:
|
|
98
|
+
loop = asyncio.get_running_loop()
|
|
99
|
+
answers = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
100
|
+
return list(dict.fromkeys(str(answer[4][0]) for answer in answers))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cache_seconds(header: str) -> int:
|
|
104
|
+
lowered = header.lower()
|
|
105
|
+
if "no-store" in lowered or "no-cache" in lowered:
|
|
106
|
+
return 0
|
|
107
|
+
found = MAX_AGE_RE.search(lowered)
|
|
108
|
+
if found is None:
|
|
109
|
+
return DEFAULT_CACHE_SECONDS
|
|
110
|
+
return min(int(found.group(1)), MAX_CACHE_SECONDS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def client_from_document(url: str, document: Any) -> HostedClient:
|
|
114
|
+
if not isinstance(document, dict):
|
|
115
|
+
raise ClientMetadataError("the client metadata document is not a JSON object")
|
|
116
|
+
if document.get("client_id") != url:
|
|
117
|
+
raise ClientMetadataError("the document's client_id is not the URL it was fetched from")
|
|
118
|
+
redirects = document.get("redirect_uris")
|
|
119
|
+
if not isinstance(redirects, list) or not redirects or not all(isinstance(uri, str) for uri in redirects):
|
|
120
|
+
raise ClientMetadataError("the document lists no redirect_uris")
|
|
121
|
+
if not all(allowed_redirect(uri) for uri in redirects):
|
|
122
|
+
raise ClientMetadataError("a redirect URI is neither https, a loopback address nor an app scheme")
|
|
123
|
+
method = document.get("token_endpoint_auth_method") or "none"
|
|
124
|
+
if method != "none" or "client_secret" in document:
|
|
125
|
+
raise ClientMetadataError("only public clients (token_endpoint_auth_method none) may use a metadata document")
|
|
126
|
+
listed = document.get("grant_types") or ["authorization_code"]
|
|
127
|
+
if not isinstance(listed, list) or "authorization_code" not in listed:
|
|
128
|
+
raise ClientMetadataError("the document does not ask for the authorization_code grant")
|
|
129
|
+
grants = [grant for grant in listed if grant in ALLOWED_GRANTS]
|
|
130
|
+
name = document.get("client_name")
|
|
131
|
+
try:
|
|
132
|
+
return HostedClient(
|
|
133
|
+
client_id=url,
|
|
134
|
+
client_name=name if isinstance(name, str) and name.strip() else urlsplit(url).hostname,
|
|
135
|
+
redirect_uris=redirects,
|
|
136
|
+
token_endpoint_auth_method="none",
|
|
137
|
+
grant_types=[*grants, "refresh_token"] if "refresh_token" not in grants else grants,
|
|
138
|
+
response_types=["code"],
|
|
139
|
+
scope=" ".join(SCOPES),
|
|
140
|
+
)
|
|
141
|
+
except ValidationError as exc:
|
|
142
|
+
raise ClientMetadataError(f"the client metadata document is malformed: {exc.error_count()} errors") from exc
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ClientMetadataFetcher:
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
transport: httpx2.AsyncBaseTransport | None = None,
|
|
149
|
+
resolver: Resolver | None = None,
|
|
150
|
+
clock: Callable[[], float] = time.monotonic,
|
|
151
|
+
) -> None:
|
|
152
|
+
self.transport = transport
|
|
153
|
+
self.resolver = resolver or system_resolver
|
|
154
|
+
self.clock = clock
|
|
155
|
+
self._cache: dict[str, tuple[HostedClient, float]] = {}
|
|
156
|
+
self._fetches: list[float] = []
|
|
157
|
+
|
|
158
|
+
async def client(self, url: str) -> HostedClient:
|
|
159
|
+
cached = self._cache.get(url)
|
|
160
|
+
if cached is not None and cached[1] > self.clock():
|
|
161
|
+
return cached[0]
|
|
162
|
+
host, port = check_metadata_url(url)
|
|
163
|
+
self._throttle()
|
|
164
|
+
document, lifetime = await self._fetch(url, host, port)
|
|
165
|
+
client = client_from_document(url, document)
|
|
166
|
+
if lifetime:
|
|
167
|
+
self._cache[url] = (client, self.clock() + lifetime)
|
|
168
|
+
else:
|
|
169
|
+
self._cache.pop(url, None)
|
|
170
|
+
return client
|
|
171
|
+
|
|
172
|
+
def _throttle(self) -> None:
|
|
173
|
+
now = self.clock()
|
|
174
|
+
self._fetches = [moment for moment in self._fetches if now - moment < 60]
|
|
175
|
+
if len(self._fetches) >= FETCHES_PER_MINUTE:
|
|
176
|
+
raise ClientMetadataError("too many client metadata fetches; try again in a minute")
|
|
177
|
+
self._fetches.append(now)
|
|
178
|
+
|
|
179
|
+
async def _fetch(self, url: str, host: str, port: int) -> tuple[Any, int]:
|
|
180
|
+
try:
|
|
181
|
+
addresses = await asyncio.wait_for(self.resolver(host, port), FETCH_TIMEOUT)
|
|
182
|
+
except (OSError, TimeoutError) as exc:
|
|
183
|
+
raise ClientMetadataError(f"cannot resolve {host}") from exc
|
|
184
|
+
if not addresses or not all(public_address(address) for address in addresses):
|
|
185
|
+
raise ClientMetadataError(f"{host} resolves to an address this server will not fetch from")
|
|
186
|
+
pinned = pinned_url(url, addresses[0])
|
|
187
|
+
headers = {"Host": host if port == 443 else f"{host}:{port}", "Accept": "application/json"}
|
|
188
|
+
try:
|
|
189
|
+
async with httpx2.AsyncClient(
|
|
190
|
+
transport=self.transport, timeout=FETCH_TIMEOUT, follow_redirects=False, trust_env=False
|
|
191
|
+
) as http:
|
|
192
|
+
request = http.build_request("GET", pinned, headers=headers, extensions={"sni_hostname": host})
|
|
193
|
+
response = await http.send(request, stream=True)
|
|
194
|
+
try:
|
|
195
|
+
body = await bounded_body(response)
|
|
196
|
+
finally:
|
|
197
|
+
await response.aclose()
|
|
198
|
+
except httpx2.HTTPError as exc:
|
|
199
|
+
raise ClientMetadataError(f"fetching the client metadata document failed: {type(exc).__name__}") from exc
|
|
200
|
+
if response.status_code != 200:
|
|
201
|
+
raise ClientMetadataError(f"the client metadata document answered {response.status_code}")
|
|
202
|
+
try:
|
|
203
|
+
document = json.loads(body)
|
|
204
|
+
except ValueError as exc:
|
|
205
|
+
raise ClientMetadataError("the client metadata document is not JSON") from exc
|
|
206
|
+
return document, cache_seconds(response.headers.get("cache-control", ""))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pinned_url(url: str, address: str) -> str:
|
|
210
|
+
parts = urlsplit(url)
|
|
211
|
+
ip = ipaddress.ip_address(address)
|
|
212
|
+
netloc = f"[{ip}]" if ip.version == 6 else str(ip)
|
|
213
|
+
if parts.port:
|
|
214
|
+
netloc += f":{parts.port}"
|
|
215
|
+
return urlunsplit(("https", netloc, parts.path, parts.query, ""))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def bounded_body(response: httpx2.Response) -> bytes:
|
|
219
|
+
declared = response.headers.get("content-length")
|
|
220
|
+
if declared is not None and declared.isdigit() and int(declared) > MAX_DOCUMENT_BYTES:
|
|
221
|
+
raise ClientMetadataError(f"the client metadata document is larger than {MAX_DOCUMENT_BYTES} bytes")
|
|
222
|
+
chunks: list[bytes] = []
|
|
223
|
+
size = 0
|
|
224
|
+
async for chunk in response.aiter_bytes():
|
|
225
|
+
size += len(chunk)
|
|
226
|
+
if size > MAX_DOCUMENT_BYTES:
|
|
227
|
+
raise ClientMetadataError(f"the client metadata document is larger than {MAX_DOCUMENT_BYTES} bytes")
|
|
228
|
+
chunks.append(chunk)
|
|
229
|
+
return b"".join(chunks)
|
mindin/auth/database.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
5
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Connection(Protocol):
|
|
11
|
+
"""One open connection: qmark parameters, rows readable by column name and by index."""
|
|
12
|
+
|
|
13
|
+
def execute(self, sql: str, parameters: Sequence[Any] = (), /) -> Any: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Step = Callable[[Any], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Database(Protocol):
|
|
20
|
+
"""Where the account store keeps its tables."""
|
|
21
|
+
|
|
22
|
+
dialect: str
|
|
23
|
+
IntegrityError: type[Exception]
|
|
24
|
+
|
|
25
|
+
def connect(self) -> AbstractContextManager[Connection]:
|
|
26
|
+
"""A connection that commits when the block ends cleanly and rolls back when it raises."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def tables(self, connection: Connection) -> list[str]: ...
|
|
30
|
+
|
|
31
|
+
def columns(self, connection: Connection, table: str) -> list[str]: ...
|
|
32
|
+
|
|
33
|
+
def create(self, connection: Connection, script: str) -> None:
|
|
34
|
+
"""Run a script of CREATE … IF NOT EXISTS statements, every time the store opens."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def migrate(self, steps: Sequence[Step]) -> None:
|
|
38
|
+
"""Run, in one transaction, the upgrade steps numbered from 1 that this database has not run yet."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SqliteDatabase:
|
|
43
|
+
dialect = "sqlite"
|
|
44
|
+
IntegrityError: type[Exception] = sqlite3.IntegrityError
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: Path) -> None:
|
|
47
|
+
self.path = path
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def connect(self) -> Iterator[sqlite3.Connection]:
|
|
51
|
+
connection = sqlite3.connect(self.path)
|
|
52
|
+
connection.row_factory = sqlite3.Row
|
|
53
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
54
|
+
try:
|
|
55
|
+
yield connection
|
|
56
|
+
connection.commit()
|
|
57
|
+
finally:
|
|
58
|
+
connection.close()
|
|
59
|
+
|
|
60
|
+
def tables(self, connection: sqlite3.Connection) -> list[str]:
|
|
61
|
+
return tables(connection)
|
|
62
|
+
|
|
63
|
+
def columns(self, connection: sqlite3.Connection, table: str) -> list[str]:
|
|
64
|
+
return columns(connection, table)
|
|
65
|
+
|
|
66
|
+
def create(self, connection: sqlite3.Connection, script: str) -> None:
|
|
67
|
+
connection.executescript(script)
|
|
68
|
+
|
|
69
|
+
def migrate(self, steps: Sequence[Step]) -> None:
|
|
70
|
+
connection = sqlite3.connect(self.path, isolation_level=None)
|
|
71
|
+
connection.row_factory = sqlite3.Row
|
|
72
|
+
try:
|
|
73
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
74
|
+
try:
|
|
75
|
+
version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
76
|
+
for number, step in enumerate(steps, start=1):
|
|
77
|
+
if version < number:
|
|
78
|
+
step(connection)
|
|
79
|
+
if version < len(steps):
|
|
80
|
+
connection.execute(f"PRAGMA user_version = {len(steps)}")
|
|
81
|
+
connection.execute("COMMIT")
|
|
82
|
+
except BaseException:
|
|
83
|
+
connection.execute("ROLLBACK")
|
|
84
|
+
raise
|
|
85
|
+
finally:
|
|
86
|
+
connection.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tables(connection: sqlite3.Connection) -> list[str]:
|
|
90
|
+
return [row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def columns(connection: sqlite3.Connection, table: str) -> list[str]:
|
|
94
|
+
return [row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_table(connection: sqlite3.Connection, table: str) -> bool:
|
|
98
|
+
return table in tables(connection)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def add_column(connection: sqlite3.Connection, table: str, column: str, declaration: str) -> None:
|
|
102
|
+
if has_table(connection, table) and column not in columns(connection, table):
|
|
103
|
+
connection.execute(f'ALTER TABLE "{table}" ADD COLUMN {column} {declaration}')
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def account_tables(database: Database, connection: Connection) -> list[str]:
|
|
107
|
+
return [table for table in database.tables(connection) if "account_id" in database.columns(connection, table)]
|