buntu 0.1.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.
- buntu-0.1.0.dist-info/METADATA +141 -0
- buntu-0.1.0.dist-info/RECORD +90 -0
- buntu-0.1.0.dist-info/WHEEL +5 -0
- buntu-0.1.0.dist-info/licenses/LICENSE +21 -0
- buntu-0.1.0.dist-info/top_level.txt +1 -0
- idp/__init__.py +0 -0
- idp/app.py +114 -0
- idp/cli.py +340 -0
- idp/config.py +121 -0
- idp/db/__init__.py +76 -0
- idp/extensions.py +6 -0
- idp/models/__init__.py +54 -0
- idp/models/account.py +145 -0
- idp/models/clients.py +155 -0
- idp/models/grants.py +157 -0
- idp/models/platform.py +253 -0
- idp/models/users.py +87 -0
- idp/oauth/__init__.py +0 -0
- idp/oauth/claims.py +33 -0
- idp/oauth/grants.py +320 -0
- idp/oauth/jwt.py +101 -0
- idp/oauth/server.py +369 -0
- idp/plugins/__init__.py +270 -0
- idp/plugins/sync.py +152 -0
- idp/security/__init__.py +0 -0
- idp/security/crypto.py +30 -0
- idp/security/csrf.py +34 -0
- idp/security/data/breached.txt.gz +0 -0
- idp/security/keys.py +67 -0
- idp/security/passwords.py +48 -0
- idp/security/tokens.py +40 -0
- idp/security/totp.py +56 -0
- idp/services/__init__.py +0 -0
- idp/services/accounts.py +452 -0
- idp/services/activity.py +73 -0
- idp/services/audit.py +22 -0
- idp/services/backchannel.py +142 -0
- idp/services/closing.py +147 -0
- idp/services/connected.py +177 -0
- idp/services/consent.py +25 -0
- idp/services/credentials.py +58 -0
- idp/services/devices.py +30 -0
- idp/services/mail.py +86 -0
- idp/services/mfa.py +155 -0
- idp/services/own_apps.py +24 -0
- idp/services/passkeys.py +252 -0
- idp/services/platform.py +1024 -0
- idp/services/profile.py +190 -0
- idp/services/retention.py +90 -0
- idp/services/security_prompt.py +18 -0
- idp/services/session_check.py +91 -0
- idp/services/sessions.py +217 -0
- idp/services/setup.py +164 -0
- idp/static/idp/assets/account-D-MHfC1z.js +1 -0
- idp/static/idp/assets/account-DN_Addf0.js +1 -0
- idp/static/idp/assets/app-CzfYFveQ.js +147 -0
- idp/static/idp/assets/di-D0iZ5SU4.js +1 -0
- idp/static/idp/assets/di-dx_XE3Av.js +1 -0
- idp/static/idp/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- idp/static/idp/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- idp/static/idp/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- idp/static/idp/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- idp/static/idp/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- idp/static/idp/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- idp/static/idp/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- idp/static/idp/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- idp/static/idp/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- idp/static/idp/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- idp/static/idp/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- idp/static/idp/assets/i18n-BZyjOlbu.js +1 -0
- idp/static/idp/assets/i18n-Dog5o8z6.js +1 -0
- idp/static/idp/assets/roboto-latin-variable-ccAYIvAh.woff2 +0 -0
- idp/static/idp/assets/schemas-Bl_iabpv.js +65 -0
- idp/static/idp/assets/secure-account-Dl1pS_Wo.js +1 -0
- idp/static/idp/assets/service-BhG4-X2O.js +1 -0
- idp/static/idp/assets/service-vCuiuvPs.js +1 -0
- idp/static/idp/assets/theme-toggle-C07m3YIv.js +1 -0
- idp/static/idp/assets/two-step-section-DMQmuG2q.js +2 -0
- idp/static/idp/assets/webauthn-BQas4tAd.js +1 -0
- idp/static/idp/idp.css +2 -0
- idp/static/idp/idp.js +2 -0
- idp/templates/shell.html +15 -0
- idp/web/__init__.py +0 -0
- idp/web/admin.py +901 -0
- idp/web/api.py +717 -0
- idp/web/developer.py +628 -0
- idp/web/oidc.py +394 -0
- idp/web/openapi.py +1158 -0
- idp/web/pages.py +238 -0
- idp/web/validation.py +46 -0
idp/cli.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Operator commands: `flask admin create | check`, `flask platform setup`, `flask keys generate`,
|
|
3
|
+
`flask clients create`, `flask users admins | promote | demote | delete`,
|
|
4
|
+
`flask plugins list | sync`. A client secret is printed once, at creation, and never stored in
|
|
5
|
+
the clear.
|
|
6
|
+
|
|
7
|
+
A superadmin is made here and nowhere else, before the platform is deployed: the person who
|
|
8
|
+
controls the server is the root of trust. The console then lets a superadmin manage the admins.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from fnmatch import fnmatchcase
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
from email_validator import EmailNotValidError, validate_email
|
|
16
|
+
from flask import Flask
|
|
17
|
+
from sqlalchemy import select
|
|
18
|
+
|
|
19
|
+
from idp.config import get_settings
|
|
20
|
+
from idp.db import db, utcnow
|
|
21
|
+
from idp.models import Client, Organization, OrganizationMember, User
|
|
22
|
+
from idp.models.clients import AUTH_METHODS, GRANT_TYPES
|
|
23
|
+
from idp.security import passwords
|
|
24
|
+
from idp.security.keys import generate_key
|
|
25
|
+
from idp.services import audit, closing, mfa, platform, retention
|
|
26
|
+
from idp.services.credentials import issue_client_secret
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register(app: Flask) -> None:
|
|
30
|
+
import click
|
|
31
|
+
|
|
32
|
+
@app.cli.group()
|
|
33
|
+
def keys() -> None:
|
|
34
|
+
"""Token signing keys."""
|
|
35
|
+
|
|
36
|
+
@keys.command("generate")
|
|
37
|
+
def keys_generate() -> None:
|
|
38
|
+
"""Add a signing key; the newest signs, the others still verify."""
|
|
39
|
+
path = generate_key(get_settings().signing_keys_dir)
|
|
40
|
+
click.echo(f"Wrote {path.name}. Restart the provider to start signing with it.")
|
|
41
|
+
|
|
42
|
+
@app.cli.group()
|
|
43
|
+
def clients() -> None:
|
|
44
|
+
"""OAuth clients."""
|
|
45
|
+
|
|
46
|
+
@clients.command("create")
|
|
47
|
+
@click.option("--client-id", required=True)
|
|
48
|
+
@click.option("--name", required=True)
|
|
49
|
+
@click.option("--redirect-uri", "redirect_uris", multiple=True, required=True)
|
|
50
|
+
@click.option("--post-logout-redirect-uri", "logout_uris", multiple=True)
|
|
51
|
+
@click.option("--type", "client_type", type=click.Choice(["public", "confidential"]), default="public")
|
|
52
|
+
@click.option("--grant-type", "grant_types", multiple=True, type=click.Choice(GRANT_TYPES))
|
|
53
|
+
@click.option("--scope", default="openid profile email offline_access")
|
|
54
|
+
@click.option("--first-party", is_flag=True, help="Skip the consent screen.")
|
|
55
|
+
@click.option(
|
|
56
|
+
"--homepage-url", help="Where the app lives; a first-party app is then listed on account pages."
|
|
57
|
+
)
|
|
58
|
+
def clients_create(
|
|
59
|
+
client_id: str,
|
|
60
|
+
name: str,
|
|
61
|
+
redirect_uris: tuple[str, ...],
|
|
62
|
+
logout_uris: tuple[str, ...],
|
|
63
|
+
client_type: str,
|
|
64
|
+
grant_types: tuple[str, ...],
|
|
65
|
+
scope: str,
|
|
66
|
+
first_party: bool,
|
|
67
|
+
homepage_url: str | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Register a client. A confidential client's secret is shown once."""
|
|
70
|
+
if db().scalar(select(Client.id).where(Client.client_id == client_id)) is not None:
|
|
71
|
+
raise click.ClickException(f"{client_id} already exists")
|
|
72
|
+
confidential = client_type == "confidential"
|
|
73
|
+
method = "client_secret_basic" if confidential else "none"
|
|
74
|
+
assert method in AUTH_METHODS
|
|
75
|
+
client = Client(
|
|
76
|
+
client_id=client_id,
|
|
77
|
+
name=name,
|
|
78
|
+
client_type=client_type,
|
|
79
|
+
organization=platform.platform_organization(),
|
|
80
|
+
environment="live",
|
|
81
|
+
redirect_uris=list(redirect_uris),
|
|
82
|
+
post_logout_redirect_uris=list(logout_uris),
|
|
83
|
+
grant_types=list(grant_types) or ["authorization_code", "refresh_token"],
|
|
84
|
+
scope=scope,
|
|
85
|
+
token_endpoint_auth_method=method,
|
|
86
|
+
first_party=first_party,
|
|
87
|
+
homepage_url=homepage_url,
|
|
88
|
+
)
|
|
89
|
+
db().add(client)
|
|
90
|
+
secret = issue_client_secret(client) if confidential else None
|
|
91
|
+
db().commit()
|
|
92
|
+
click.echo(f"Created {client_id} ({client_type}).")
|
|
93
|
+
if secret:
|
|
94
|
+
click.echo(f"Secret (shown once, store it now): {secret}")
|
|
95
|
+
|
|
96
|
+
@clients.command("homepage")
|
|
97
|
+
@click.argument("client_id")
|
|
98
|
+
@click.argument("url", required=False)
|
|
99
|
+
def clients_homepage(client_id: str, url: str | None) -> None:
|
|
100
|
+
"""Set where a client lives, or clear it with no URL."""
|
|
101
|
+
client = db().scalar(select(Client).where(Client.client_id == client_id))
|
|
102
|
+
if client is None:
|
|
103
|
+
raise click.ClickException(f"no client {client_id}")
|
|
104
|
+
if url and (urlparse(url).scheme not in ("http", "https") or not urlparse(url).netloc):
|
|
105
|
+
raise click.ClickException("expected an http or https URL")
|
|
106
|
+
client.homepage_url = url or None
|
|
107
|
+
db().commit()
|
|
108
|
+
click.echo(f"{client_id}: {client.homepage_url or 'no homepage'}")
|
|
109
|
+
|
|
110
|
+
@app.cli.group()
|
|
111
|
+
def users() -> None:
|
|
112
|
+
"""Accounts."""
|
|
113
|
+
|
|
114
|
+
def account(email: str) -> User:
|
|
115
|
+
user = db().scalar(select(User).where(User.email == email.strip().lower()))
|
|
116
|
+
if user is None:
|
|
117
|
+
raise click.ClickException("no account with that email")
|
|
118
|
+
return user
|
|
119
|
+
|
|
120
|
+
def admins() -> list[User]:
|
|
121
|
+
return [u for u in db().scalars(select(User).order_by(User.created_at)) if u.is_admin]
|
|
122
|
+
|
|
123
|
+
@users.command("admins")
|
|
124
|
+
def users_admins() -> None:
|
|
125
|
+
"""List the accounts with the admin role, and whether they use two-step sign-in."""
|
|
126
|
+
found = admins()
|
|
127
|
+
if not found:
|
|
128
|
+
click.echo("No admin. Create one with `flask admin create`.")
|
|
129
|
+
for user in found:
|
|
130
|
+
two_step = "two-step on" if mfa.is_enabled(user) else "two-step OFF: the console stays closed"
|
|
131
|
+
state = ", disabled" if user.disabled_at is not None else ""
|
|
132
|
+
click.echo(f"{user.email} ({two_step}{state})")
|
|
133
|
+
|
|
134
|
+
@users.command("promote")
|
|
135
|
+
@click.argument("email")
|
|
136
|
+
@click.option("--super", "superadmin", is_flag=True, help="Superadmin: also manages the admins.")
|
|
137
|
+
def users_promote(email: str, superadmin: bool) -> None:
|
|
138
|
+
"""Give an existing account the admin role."""
|
|
139
|
+
user = account(email)
|
|
140
|
+
if not user.is_admin:
|
|
141
|
+
audit.record("admin.role_granted", user=user, source="cli")
|
|
142
|
+
platform.grant_admin(user)
|
|
143
|
+
if superadmin and not user.is_superadmin:
|
|
144
|
+
user.roles = [*user.roles, "superadmin"]
|
|
145
|
+
audit.record("admin.superadmin_granted", user=user, source="cli")
|
|
146
|
+
db().commit()
|
|
147
|
+
click.echo(f"{user.email} is an admin.")
|
|
148
|
+
if not mfa.is_enabled(user):
|
|
149
|
+
click.echo("Turn on two-step sign-in for this account before using the console.")
|
|
150
|
+
|
|
151
|
+
@users.command("demote")
|
|
152
|
+
@click.argument("email")
|
|
153
|
+
def users_demote(email: str) -> None:
|
|
154
|
+
"""Take the admin role away. The admin API refuses the account from its next request."""
|
|
155
|
+
user = account(email)
|
|
156
|
+
if user.is_admin:
|
|
157
|
+
audit.record("admin.role_removed", user=user, source="cli")
|
|
158
|
+
platform.revoke_admin(user)
|
|
159
|
+
db().commit()
|
|
160
|
+
click.echo(f"{user.email} is no longer an admin.")
|
|
161
|
+
if not admins():
|
|
162
|
+
click.echo("No admin remains. Create one with `flask admin create` when you need the console.")
|
|
163
|
+
|
|
164
|
+
@users.command("delete")
|
|
165
|
+
@click.argument("emails", nargs=-1, required=True)
|
|
166
|
+
@click.option("--yes", is_flag=True, help="Delete without asking.")
|
|
167
|
+
def users_delete(emails: tuple[str, ...], yes: bool) -> None:
|
|
168
|
+
"""Delete accounts for good, with every organization left without a member.
|
|
169
|
+
|
|
170
|
+
Each argument is an address or a pattern with `*` (quote it for the shell). What will go
|
|
171
|
+
is listed first; nothing happens until it is confirmed. The last admin is never deleted.
|
|
172
|
+
"""
|
|
173
|
+
everyone = list(db().scalars(select(User).order_by(User.email)))
|
|
174
|
+
found: list[User] = []
|
|
175
|
+
for wanted in emails:
|
|
176
|
+
pattern = wanted.strip().lower()
|
|
177
|
+
matched = [u for u in everyone if fnmatchcase(u.email, pattern)]
|
|
178
|
+
if not matched:
|
|
179
|
+
raise click.ClickException(f"no account matches {wanted}")
|
|
180
|
+
found += [u for u in matched if u not in found]
|
|
181
|
+
ids = {user.id for user in found}
|
|
182
|
+
if admins() and all(user.id in ids for user in admins()):
|
|
183
|
+
raise click.ClickException("that would delete every admin; keep one, or create another first")
|
|
184
|
+
# The organizations whose members are all going: nobody would be left to run them.
|
|
185
|
+
orphaned = [
|
|
186
|
+
organization
|
|
187
|
+
for organization in db().scalars(
|
|
188
|
+
select(Organization)
|
|
189
|
+
.join(OrganizationMember)
|
|
190
|
+
.where(OrganizationMember.user_id.in_(ids))
|
|
191
|
+
.distinct()
|
|
192
|
+
)
|
|
193
|
+
if all(member.user_id in ids for member in organization.members)
|
|
194
|
+
]
|
|
195
|
+
for user in found:
|
|
196
|
+
click.echo(f"account {user.email}{' (admin)' if user.is_admin else ''}")
|
|
197
|
+
for organization in orphaned:
|
|
198
|
+
click.echo(f"organization {organization.name} ({organization.id})")
|
|
199
|
+
if not yes:
|
|
200
|
+
click.confirm(f"Delete {len(found)} accounts and {len(orphaned)} organizations?", abort=True)
|
|
201
|
+
for organization in orphaned:
|
|
202
|
+
audit.record(
|
|
203
|
+
"organization.deleted",
|
|
204
|
+
source="cli",
|
|
205
|
+
organization=str(organization.id),
|
|
206
|
+
name=organization.name,
|
|
207
|
+
)
|
|
208
|
+
db().delete(organization)
|
|
209
|
+
for user in found:
|
|
210
|
+
closing.delete(user, source="cli")
|
|
211
|
+
db().commit()
|
|
212
|
+
click.echo(f"Deleted {len(found)} accounts and {len(orphaned)} organizations.")
|
|
213
|
+
|
|
214
|
+
@app.cli.command("prune")
|
|
215
|
+
def prune() -> None:
|
|
216
|
+
"""Deletes expired codes and tokens, ended sign-ins and old audit rows. Run it daily."""
|
|
217
|
+
for kind, count in retention.prune().items():
|
|
218
|
+
click.echo(f"{kind:<20} {count}")
|
|
219
|
+
|
|
220
|
+
@app.cli.group("plugins")
|
|
221
|
+
def plugins_group() -> None:
|
|
222
|
+
"""The deployment's plugins (IDP_PLUGINS)."""
|
|
223
|
+
|
|
224
|
+
@plugins_group.command("list")
|
|
225
|
+
def plugins_list() -> None:
|
|
226
|
+
"""The plugins this deployment runs, and what their catalogue declares."""
|
|
227
|
+
from idp import plugins
|
|
228
|
+
|
|
229
|
+
running = plugins.active()
|
|
230
|
+
if not running:
|
|
231
|
+
click.echo("No plugin. Name them in IDP_PLUGINS.")
|
|
232
|
+
for plugin in running:
|
|
233
|
+
catalogue = plugin.catalogue()
|
|
234
|
+
counts = f"{len(catalogue.products)} products, {len(catalogue.scopes)} scopes"
|
|
235
|
+
click.echo(f"{plugin.id} {plugin.version}: {counts}")
|
|
236
|
+
|
|
237
|
+
@plugins_group.command("sync")
|
|
238
|
+
def plugins_sync() -> None:
|
|
239
|
+
"""Bring products and scopes in line with what the plugins declare. Run it on deploy."""
|
|
240
|
+
from idp import plugins
|
|
241
|
+
from idp.plugins.sync import CatalogueError, sync
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
report = sync(plugins.active())
|
|
245
|
+
except CatalogueError as error:
|
|
246
|
+
raise click.ClickException(str(error)) from error
|
|
247
|
+
if not report:
|
|
248
|
+
click.echo("The catalogue is up to date.")
|
|
249
|
+
for kind in ("added", "updated", "adopted", "restored", "deprecated"):
|
|
250
|
+
for entry in getattr(report, kind):
|
|
251
|
+
click.echo(f"{kind:<10} {entry}")
|
|
252
|
+
|
|
253
|
+
@app.cli.group("admin")
|
|
254
|
+
def admin_group() -> None:
|
|
255
|
+
"""The owner's account."""
|
|
256
|
+
|
|
257
|
+
@admin_group.command("check")
|
|
258
|
+
def admin_check() -> None:
|
|
259
|
+
"""Exit with 1 when there is no superadmin yet: for install scripts."""
|
|
260
|
+
found = [u.email for u in db().scalars(select(User)) if u.is_superadmin]
|
|
261
|
+
if not found:
|
|
262
|
+
click.echo("No superadmin yet.")
|
|
263
|
+
raise SystemExit(1)
|
|
264
|
+
click.echo(f"Superadmin: {', '.join(found)}")
|
|
265
|
+
|
|
266
|
+
@admin_group.command("create")
|
|
267
|
+
@click.option("--email", prompt=True)
|
|
268
|
+
@click.option("--given-name", prompt="First name")
|
|
269
|
+
@click.option("--family-name", prompt="Last name")
|
|
270
|
+
@click.password_option(prompt="Password (at least 10 characters)")
|
|
271
|
+
def admin_create(email: str, given_name: str, family_name: str, password: str) -> None:
|
|
272
|
+
"""Create a superadmin: email verified, admin and superadmin roles. Two-step sign-in comes next."""
|
|
273
|
+
try:
|
|
274
|
+
address = validate_email(email.strip(), check_deliverability=False).normalized.lower()
|
|
275
|
+
except EmailNotValidError as error:
|
|
276
|
+
raise click.ClickException(f"not a valid email: {error}") from None
|
|
277
|
+
if db().scalar(select(User.id).where(User.email == address)) is not None:
|
|
278
|
+
raise click.ClickException(
|
|
279
|
+
f"{address} already has an account: use `flask users promote {address}`"
|
|
280
|
+
)
|
|
281
|
+
if wrong := passwords.problem(password):
|
|
282
|
+
raise click.ClickException(
|
|
283
|
+
"the password is too well known: it appears in breached password lists"
|
|
284
|
+
if wrong == "breached_password"
|
|
285
|
+
else "the password needs 10 to 256 characters"
|
|
286
|
+
)
|
|
287
|
+
user = User(
|
|
288
|
+
email=address,
|
|
289
|
+
password_hash=passwords.hash_password(password),
|
|
290
|
+
given_name=given_name.strip()[:100],
|
|
291
|
+
family_name=family_name.strip()[:100],
|
|
292
|
+
email_verified_at=utcnow(),
|
|
293
|
+
roles=["admin", "superadmin"],
|
|
294
|
+
)
|
|
295
|
+
db().add(user)
|
|
296
|
+
db().flush()
|
|
297
|
+
platform.join_platform(user)
|
|
298
|
+
audit.record("admin.account_created", user=user, source="cli")
|
|
299
|
+
db().commit()
|
|
300
|
+
issuer = get_settings().issuer
|
|
301
|
+
click.echo(f"Created {address}, superadmin.")
|
|
302
|
+
click.echo(f"Next: sign in at {issuer}/account and turn on two-step sign-in; the console needs it.")
|
|
303
|
+
|
|
304
|
+
@app.cli.group("platform")
|
|
305
|
+
def platform_group() -> None:
|
|
306
|
+
"""First install."""
|
|
307
|
+
|
|
308
|
+
@platform_group.command("setup")
|
|
309
|
+
@click.option(
|
|
310
|
+
"--file",
|
|
311
|
+
"path",
|
|
312
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
313
|
+
default="platform.toml",
|
|
314
|
+
show_default=True,
|
|
315
|
+
)
|
|
316
|
+
@click.option(
|
|
317
|
+
"--rotate-secrets", is_flag=True, help="New secrets for apps that exist, written to their files."
|
|
318
|
+
)
|
|
319
|
+
def platform_setup(path: Path, rotate_secrets: bool) -> None:
|
|
320
|
+
"""The platform organization, the apps in the file, the plugins' catalogue. Safe to run again."""
|
|
321
|
+
from idp import plugins
|
|
322
|
+
from idp.plugins.sync import CatalogueError, sync
|
|
323
|
+
from idp.services import setup
|
|
324
|
+
|
|
325
|
+
if not any(u.is_superadmin for u in db().scalars(select(User))):
|
|
326
|
+
raise click.ClickException("no superadmin yet: run `flask admin create` first")
|
|
327
|
+
try:
|
|
328
|
+
report = setup.apply(setup.read(path), rotate_secrets=rotate_secrets)
|
|
329
|
+
catalogue = sync(plugins.active())
|
|
330
|
+
except (setup.SetupError, CatalogueError) as error:
|
|
331
|
+
raise click.ClickException(str(error)) from error
|
|
332
|
+
for client_id in report.created:
|
|
333
|
+
click.echo(f"created {client_id}")
|
|
334
|
+
for client_id in report.kept:
|
|
335
|
+
click.echo(f"kept {client_id}")
|
|
336
|
+
for target in report.secrets:
|
|
337
|
+
click.echo(f"secret {target}")
|
|
338
|
+
for kind in ("added", "updated", "adopted", "restored", "deprecated"):
|
|
339
|
+
for entry in getattr(catalogue, kind):
|
|
340
|
+
click.echo(f"{kind:<10}{entry}")
|
idp/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Settings, read from the environment (prefix IDP_) and validated once at startup."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, SecretStr, field_validator
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
model_config = SettingsConfigDict(env_prefix="IDP_", env_file=".env", extra="ignore")
|
|
14
|
+
|
|
15
|
+
# The platform's brand. One deployment, one brand: the sign-in pages (when no client brand
|
|
16
|
+
# applies), emails, authenticator apps, the developer portal and the owner console read it.
|
|
17
|
+
# Nothing is shown for an empty field.
|
|
18
|
+
brand_name: str = ""
|
|
19
|
+
brand_accent: str = ""
|
|
20
|
+
brand_logo_url: str = ""
|
|
21
|
+
brand_support_email: str = ""
|
|
22
|
+
brand_privacy_url: str = ""
|
|
23
|
+
brand_terms_url: str = ""
|
|
24
|
+
# The issuer: the provider's public origin, exactly as clients see it. No trailing slash.
|
|
25
|
+
issuer: str
|
|
26
|
+
database_url: str
|
|
27
|
+
# Signs the session cookie and the CSRF tokens.
|
|
28
|
+
secret_key: SecretStr = Field(min_length=32)
|
|
29
|
+
# A directory of private JWKs (one JSON file each); the newest signs, every one verifies.
|
|
30
|
+
signing_keys_dir: Path
|
|
31
|
+
|
|
32
|
+
# Short, because an API checking tokens offline sees a revocation only when the token expires;
|
|
33
|
+
# APIs that must see it at once introspect the token instead.
|
|
34
|
+
access_token_ttl: int = 300
|
|
35
|
+
refresh_token_ttl: int = 30 * 24 * 3600
|
|
36
|
+
authorization_code_ttl: int = 60
|
|
37
|
+
session_ttl: int = 14 * 24 * 3600
|
|
38
|
+
email_token_ttl: int = 3600
|
|
39
|
+
|
|
40
|
+
# How long `flask prune` keeps an ended sign-in (the devices list shows recent ones) and
|
|
41
|
+
# the audit trail.
|
|
42
|
+
session_retention_days: int = 30
|
|
43
|
+
audit_retention_days: int = 365
|
|
44
|
+
|
|
45
|
+
# Email through Resend. Test mode logs the message, codes and links included, instead of
|
|
46
|
+
# sending it: for development only, refused with a public issuer.
|
|
47
|
+
resend_api_key: SecretStr | None = None
|
|
48
|
+
email_from: str = "no-reply@localhost"
|
|
49
|
+
email_test_mode: bool = False
|
|
50
|
+
|
|
51
|
+
rate_limit_storage: str = "memory://"
|
|
52
|
+
rate_limit_enabled: bool = True
|
|
53
|
+
# Allowed browser origins for the token, revocation and userinfo endpoints (SPAs).
|
|
54
|
+
cors_origins: list[str] = []
|
|
55
|
+
# Secure cookies need HTTPS; browsers accept them on http://localhost too.
|
|
56
|
+
secure_cookies: bool = True
|
|
57
|
+
# How many proxies in front set X-Forwarded-For and X-Forwarded-Proto (a load balancer, then
|
|
58
|
+
# a reverse proxy: 2). 0 when clients reach the provider directly. Too high lets a client
|
|
59
|
+
# choose the address the rate limits and the audit trail see.
|
|
60
|
+
trusted_proxies: int = 0
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def local(self) -> bool:
|
|
64
|
+
"""An issuer on this machine: development and tests."""
|
|
65
|
+
return urlsplit(self.issuer).hostname in ("localhost", "127.0.0.1", "::1")
|
|
66
|
+
|
|
67
|
+
def production_problems(self) -> list[str]:
|
|
68
|
+
"""What a provider with a public issuer must not run with."""
|
|
69
|
+
if self.local:
|
|
70
|
+
return []
|
|
71
|
+
problems = []
|
|
72
|
+
if not self.issuer.startswith("https://"):
|
|
73
|
+
problems.append("IDP_ISSUER must be https")
|
|
74
|
+
if self.email_test_mode:
|
|
75
|
+
problems.append("IDP_EMAIL_TEST_MODE logs sign-in codes and reset links instead of sending them")
|
|
76
|
+
if not self.secure_cookies:
|
|
77
|
+
problems.append("IDP_SECURE_COOKIES must be true")
|
|
78
|
+
if (
|
|
79
|
+
self.rate_limit_storage.startswith("memory://")
|
|
80
|
+
and "rate_limit_storage" not in self.model_fields_set
|
|
81
|
+
):
|
|
82
|
+
problems.append(
|
|
83
|
+
"IDP_RATE_LIMIT_STORAGE: limits kept in memory are per process; "
|
|
84
|
+
"give a shared store (redis://...), or memory:// explicitly for a single process"
|
|
85
|
+
)
|
|
86
|
+
return problems
|
|
87
|
+
|
|
88
|
+
# Plugins this deployment runs, by entry point name, in order (see idp.plugins).
|
|
89
|
+
plugins: list[str] = []
|
|
90
|
+
# Bring the catalogue in line with the plugins when the app starts. Turn off where several
|
|
91
|
+
# workers start at once, and run `flask plugins sync` on deploy instead.
|
|
92
|
+
plugins_sync_on_start: bool = True
|
|
93
|
+
|
|
94
|
+
@field_validator("brand_accent")
|
|
95
|
+
@classmethod
|
|
96
|
+
def _hex_colour(cls, value: str) -> str:
|
|
97
|
+
if value and not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
|
98
|
+
raise ValueError("expected #rrggbb")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
def brand(self) -> dict[str, str]:
|
|
102
|
+
"""The brand fields that are set, keyed as clients see them."""
|
|
103
|
+
fields = {
|
|
104
|
+
"name": self.brand_name,
|
|
105
|
+
"accent": self.brand_accent,
|
|
106
|
+
"logo": self.brand_logo_url,
|
|
107
|
+
"support_email": self.brand_support_email,
|
|
108
|
+
"privacy_url": self.brand_privacy_url,
|
|
109
|
+
"terms_url": self.brand_terms_url,
|
|
110
|
+
}
|
|
111
|
+
return {key: value for key, value in fields.items() if value}
|
|
112
|
+
|
|
113
|
+
@field_validator("issuer")
|
|
114
|
+
@classmethod
|
|
115
|
+
def _no_trailing_slash(cls, value: str) -> str:
|
|
116
|
+
return value.rstrip("/")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@lru_cache
|
|
120
|
+
def get_settings() -> Settings:
|
|
121
|
+
return Settings() # type: ignore[call-arg]
|
idp/db/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The database: one engine per app, one session per request, closed when the request ends."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from flask import Flask, g
|
|
9
|
+
from sqlalchemy import DateTime, MetaData, create_engine
|
|
10
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
11
|
+
from sqlalchemy.types import Uuid
|
|
12
|
+
|
|
13
|
+
# Stable constraint names, so Alembic migrations name the same constraint the same way everywhere.
|
|
14
|
+
NAMING = {
|
|
15
|
+
"ix": "ix_%(column_0_label)s",
|
|
16
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
17
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
18
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
19
|
+
"pk": "pk_%(table_name)s",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def utcnow() -> datetime:
|
|
24
|
+
return datetime.now(UTC)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Base(DeclarativeBase):
|
|
28
|
+
metadata = MetaData(naming_convention=NAMING)
|
|
29
|
+
type_annotation_map = {datetime: DateTime(timezone=True), uuid.UUID: Uuid}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IdMixin:
|
|
33
|
+
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TimestampMixin:
|
|
37
|
+
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
|
38
|
+
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_factory: sessionmaker[Session] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def init_db(app: Flask, url: str) -> None:
|
|
45
|
+
global _factory
|
|
46
|
+
engine = create_engine(url, pool_pre_ping=True)
|
|
47
|
+
_factory = sessionmaker(engine, expire_on_commit=False)
|
|
48
|
+
app.extensions["idp.engine"] = engine
|
|
49
|
+
|
|
50
|
+
@app.teardown_appcontext
|
|
51
|
+
def _close(_exc: BaseException | None) -> None:
|
|
52
|
+
session = g.pop("db_session", None)
|
|
53
|
+
if session is not None:
|
|
54
|
+
session.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def db() -> Session:
|
|
58
|
+
"""This request's session."""
|
|
59
|
+
if "db_session" not in g:
|
|
60
|
+
if _factory is None:
|
|
61
|
+
raise RuntimeError("init_db() has not run")
|
|
62
|
+
g.db_session = _factory()
|
|
63
|
+
session: Session = g.db_session
|
|
64
|
+
return session
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def transaction() -> Iterator[Session]:
|
|
69
|
+
"""Commits on success, rolls back on any error."""
|
|
70
|
+
session = db()
|
|
71
|
+
try:
|
|
72
|
+
yield session
|
|
73
|
+
session.commit()
|
|
74
|
+
except BaseException:
|
|
75
|
+
session.rollback()
|
|
76
|
+
raise
|
idp/extensions.py
ADDED
idp/models/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Every table, imported here so Alembic and the app see the whole schema."""
|
|
2
|
+
|
|
3
|
+
from idp.models.account import (
|
|
4
|
+
AuditEvent,
|
|
5
|
+
BackupCode,
|
|
6
|
+
DeviceCode,
|
|
7
|
+
EmailToken,
|
|
8
|
+
Passkey,
|
|
9
|
+
TotpCredential,
|
|
10
|
+
UsedChallenge,
|
|
11
|
+
UserPicture,
|
|
12
|
+
)
|
|
13
|
+
from idp.models.clients import Client
|
|
14
|
+
from idp.models.grants import AccessTokenRecord, AuthorizationCode, Consent, LoginSession, RefreshToken
|
|
15
|
+
from idp.models.platform import (
|
|
16
|
+
ApiKey,
|
|
17
|
+
ApiProduct,
|
|
18
|
+
AppReview,
|
|
19
|
+
ClientSecret,
|
|
20
|
+
Invitation,
|
|
21
|
+
Organization,
|
|
22
|
+
OrganizationMember,
|
|
23
|
+
OrganizationTestUser,
|
|
24
|
+
Scope,
|
|
25
|
+
)
|
|
26
|
+
from idp.models.users import Identity, User
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Scope",
|
|
30
|
+
"OrganizationMember",
|
|
31
|
+
"OrganizationTestUser",
|
|
32
|
+
"Invitation",
|
|
33
|
+
"Organization",
|
|
34
|
+
"ClientSecret",
|
|
35
|
+
"ApiKey",
|
|
36
|
+
"AppReview",
|
|
37
|
+
"ApiProduct",
|
|
38
|
+
"AccessTokenRecord",
|
|
39
|
+
"AuditEvent",
|
|
40
|
+
"AuthorizationCode",
|
|
41
|
+
"BackupCode",
|
|
42
|
+
"Client",
|
|
43
|
+
"Consent",
|
|
44
|
+
"DeviceCode",
|
|
45
|
+
"EmailToken",
|
|
46
|
+
"UsedChallenge",
|
|
47
|
+
"UserPicture",
|
|
48
|
+
"Identity",
|
|
49
|
+
"LoginSession",
|
|
50
|
+
"RefreshToken",
|
|
51
|
+
"Passkey",
|
|
52
|
+
"TotpCredential",
|
|
53
|
+
"User",
|
|
54
|
+
]
|