Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1174257
feat(config): make the displayed product name configurable
aloCetic Aug 21, 2026
878ee61
refactor(server): load app modules before building the Tornado app
aloCetic Aug 21, 2026
6f9855b
feat(auth): PYPLET_SESSION_TTL_DAYS — promote session TTL to config P…
May 31, 2026
2b06e89
feat(auth): Drive incremental consent + server-side token hooks
May 31, 2026
b31cb2b
Test: declare pytest-asyncio in the test extra
Jun 3, 2026
50fbfc1
feat(auth): fail-closed startup policy and deny-by-default ACL
Jun 8, 2026
c9849ea
feat(auth): Secure session cookie and a required persistent cookie se…
Jun 8, 2026
a27cf85
docs(auth): state the cookie-secret and session-TTL semantics
Jun 8, 2026
f2352ee
feat(auth): verify the OIDC id_token signature against the provider JWKS
Jun 9, 2026
03cbffb
feat(server): unauthenticated /healthz liveness route
Jun 8, 2026
fce2356
feat(server): PYPLET_WS_MAX_MESSAGE_MB knob for the WebSocket frame size
Jun 9, 2026
c363c99
feat(server): Tornado production hardening — debug off, xheaders, che…
Jun 9, 2026
292ffca
docs(readme): document the production-profile guards
Jun 9, 2026
50d24f1
fix(tests): boot the e2e server anonymously on a free port
Aug 18, 2026
1162fd6
feat(server): routes() hook for app-declared Tornado handlers
May 26, 2026
e7bbaad
feat(server): thread the WS-resolved login onto ServerWebSocket
Jul 9, 2026
a1a8528
fix(server): restore permessage-deflate get_compression_options override
Jul 9, 2026
b6d4cc6
feat(server,client): boot splash during the Pyodide bootstrap
Jun 30, 2026
54f5338
fix(client): self-heal a missing micropip at boot
Jul 13, 2026
19ab482
oauth: replace Google/Drive hardcoding with a provider registry
Aug 25, 2026
f9abcac
docs(oauth): drop the false "byte-for-byte" migration claim
Aug 25, 2026
7671feb
Merge branch 'degoogle/oauth-generic-providers' into 'core/client-boot'
aloCetic Aug 25, 2026
caab537
Merge branch 'core/site-name' into 'main'
aloCetic Aug 26, 2026
95fbd70
Merge branch 'core/auth-fail-closed' into 'main'
aloCetic Aug 26, 2026
33d1644
Merge branch 'core/prod-hardening' into 'main'
aloCetic Aug 26, 2026
fee9770
Merge branch 'core/server-hooks-ws' into 'main'
aloCetic Aug 26, 2026
1feb1a9
Merge branch 'core/client-boot' into 'main'
aloCetic Aug 26, 2026
7128406
fix(security): confine app static route to per-app static/ dir (path …
Jul 3, 2026
7038885
fix(security): handle HEAD in AppStaticFileHandler (regression from 6…
Jul 3, 2026
59c0666
fix(security): refuse symlink escapes from app static/ dirs
Aug 26, 2026
a585412
Merge branch 'core/app-static-confinement' into 'main'
aloCetic Aug 26, 2026
5d39681
test(ws): pin permessage-deflate on the shared ServerWebSocket
Aug 26, 2026
1497bcf
Merge branch 'tests/ws-compression' into 'main'
aloCetic Aug 26, 2026
1c93a7f
docs(readme): document the GitLab->GitHub one-way flow
aloCetic Aug 26, 2026
553220e
Merge branch 'docs-repo-flow-direction' into 'main'
aloCetic Aug 26, 2026
34bf363
fix(cli): stop freezing env-sourced config params on every `start`
Vincent-Stragier Aug 28, 2026
a123c45
fix(config): stop config.__dict__ test restores from freezing params
Vincent-Stragier Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 60 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ secret. Set the callback URL to:
**2. Set environment variables:**

```bash
# Required to sign session cookies (generate once and keep it stable):
# Required & persistent in production — generate once and keep it stable.
# Under PYPLET_REQUIRE_AUTH=1 the server refuses to boot when this is unset
# (a per-process random secret logs out every user on each restart):
export PYPLET_COOKIE_SECRET=$(python -c "import secrets; print(secrets.token_hex(32))")

# Google
Expand All @@ -193,9 +195,8 @@ export OAUTH_MICROSOFT_TENANT=common # or your tenant ID

### Access control (ACL)

By default every authenticated user can see all apps. To restrict access,
create `apps/auth_rules.json` — a JSON array of
`["project/app regex", "email regex"]` pairs:
To restrict which apps each user can see, create `apps/auth_rules.json` — a
JSON array of `["project/app regex", "email regex"]` pairs:

```json
[
Expand All @@ -211,6 +212,12 @@ If no rule matches, access is denied.

Override the rules file path with `PYPLET_AUTH_RULES_FILE`.

**Deny-by-default (fail closed):** when authentication is enabled but the rules
file is **missing**, access is **denied** to every app — ship `auth_rules.json`
in your deploy artifact. (When auth is fully disabled — local dev with no
provider — a missing file still allows all apps, so an un-authenticated local
run works.)

### Magic-link e-mail authentication

As an alternative (or complement) to OAuth, users can sign in by entering their
Expand All @@ -237,11 +244,39 @@ The ACL rules file applies to magic-link logins exactly the same way it does
for OAuth: the user's e-mail address is matched against the `email_regex`
column of each rule.

Because magic-link mints a session for **any** e-mail that can receive the
link, it is **refused at boot on the production profile** (`PYPLET_REQUIRE_AUTH=1`,
below) unless you opt in explicitly with `PYPLET_ALLOW_MAGICLINK=1`.

### Production fail-closed startup (`PYPLET_REQUIRE_AUTH`)

On any non-local deployment, set `PYPLET_REQUIRE_AUTH=1`. With it, the server
**refuses to boot** (exits non-zero with a logged error) rather than silently
serving anonymously when the auth config is misdelivered — specifically when
**no** auth method is configured, when `auth_rules.json` is **missing**, or
when magic-link is enabled **without** `PYPLET_ALLOW_MAGICLINK=1`. Without the
flag (the default), a deployment with no provider still starts but logs a loud
WARNING that every request is served anonymously.

Three further production-profile guards ship with this posture. The server
**refuses to boot when `PYPLET_DEBUG=1` under `PYPLET_REQUIRE_AUTH=1`** —
Tornado debug mode enables autoreload and exposes traceback pages, so set
`PYPLET_DEBUG=0` in production. Behind a TLS-terminating reverse proxy,
`app.listen` trusts `X-Forwarded-For`/`X-Forwarded-Proto` (`xheaders`) and
WebSocket upgrades are origin-checked against the `PYPLET_URL` host
(same-origin when `PYPLET_URL` is unset). At login, OIDC `id_token`s are
verified against the provider JWKS (RS256 signature, issuer, audience and
expiry) before a session is established.

### Configuration reference

| Variable | Description |
| --- | --- |
| `PYPLET_COOKIE_SECRET` | Secret for signing session cookies |
| `PYPLET_SECURE_COOKIES` | Force `Secure` attribute on auth cookies: `1`/`0` |
| `PYPLET_REQUIRE_AUTH` | Fail-closed switch: `1` refuses boot, default `0` |
| `PYPLET_ALLOW_MAGICLINK` | Opt magic-link IN on require-auth, default `0` |
| `PYPLET_SESSION_TTL_DAYS` | Session cookie lifetime in days, default `1` |
| **OAuth — Google** | |
| `OAUTH_GOOGLE_CLIENT_ID` | Google OAuth2 client ID |
| `OAUTH_GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret |
Expand All @@ -260,6 +295,10 @@ column of each rule.
| **ACL** | |
| `PYPLET_AUTH_RULES_FILE` | ACL rules path (default: `apps/auth_rules.json`) |

`PYPLET_COOKIE_SECRET` must be persistent and is required under
`PYPLET_REQUIRE_AUTH=1` (the server refuses to boot when unset);
`PYPLET_SECURE_COOKIES`, when unset, follows the `PYPLET_URL` scheme.

## Advanced Features

### DOM Manipulation
Expand Down Expand Up @@ -337,9 +376,10 @@ Available configuration options:
- `--address` / `PYPLET_ADDR` - Server address (default: `127.0.0.1`)
- `--port` / `PYPLET_PORT` - Server port (default: `8080`)
- `--apps` / `PYPLET_APPS` - Apps directory (default: `apps`)
- `--debug` / `PYPLET_DEBUG` - Debug mode (default: `1`)
- `--debug` / `PYPLET_DEBUG` - Debug mode (default `1`; must be `0` in prod)
- `--pyodide-url` / `PYPLET_PYODIDE` - Pyodide CDN URL
- `--url` / `PYPLET_URL` - Custom URL override
- `PYPLET_WS_MAX_MESSAGE_MB` - Max WebSocket frame size MB (default: `40`)

See the [Authentication](#authentication) section for OAuth-related variables.

Expand Down Expand Up @@ -388,6 +428,21 @@ Since client code runs in PyScript (WebAssembly):

## Contributing

### Where development happens

Pyplet lives in two places, and they are not interchangeable:

- **GitLab `seglab/pyplet`** (CETIC forge, `git.cetic.be`) is the
**canonical** repository. Development lands there, on the default
branch `main`, through merge requests.
- **GitHub [`cetic/Pyplet`](https://github.com/cetic/Pyplet/)** is the
**publication mirror**. It is deliberately behind: nothing is developed
there, and it is refreshed from GitLab `main` by a maintainer when a
state is worth publishing.

The flow is one-way: **GitLab `main` → GitHub**. A change pushed straight
to GitHub would be overwritten by the next publication.

Contributions are welcome! When contributing:

1. Maintain clean separation between client and server code
Expand Down
123 changes: 123 additions & 0 deletions docs/api/oauth-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# OAuth providers and consent flows

`pyplet.server.oauth` is a provider-agnostic OIDC engine. It owns discovery,
the CSRF state cookie, the code exchange, JWKS signature verification, the
signed session cookie and the fail-closed startup policy — and it branches on
no provider name and hardcodes no vendor endpoint.

Everything vendor-specific lives in one of two places:

- **`pyplet.server.oauth_providers`** — the presets Pyplet ships (Google,
Microsoft/Entra ID), registered into the engine at import.
- **Your application** — anything else, registered from your own module at
import time.

## Registering a provider

```python
from pyplet.server import oauth

oauth.register_provider("corporate", {
"label": "Corporate SSO", # login-button text
"openid_config_url": "https://id.corp.example/.well-known/openid-configuration",
"client_id": lambda: os.environ.get("CORP_CLIENT_ID", ""),
"client_secret": lambda: os.environ.get("CORP_CLIENT_SECRET", ""),
"scopes": ["openid", "email", "profile"],
"auth_params": {"prompt": "select_account"}, # optional
})
```

`openid_config_url`, `client_id` and `client_secret` are required; a spec
missing one raises `ValueError` **at registration**, so the traceback names the
app instead of surfacing as a broken login in production.

Any value may be a zero-argument callable, resolved at use time. That is how a
spec reads configuration lazily rather than freezing an env var at import.

A provider only appears on the login page once its `client_id` resolves to
something non-empty — registration is not configuration. Registering an
existing name **replaces** it, which is how an app overrides a shipped preset
(apps are loaded before the Tornado app is built, so an app-side registration
always wins).

## Incremental consent

An incremental-consent flow re-runs the authorization-code round-trip on top of
an existing login to obtain **extra scopes** — and, if it asks for offline
access, a refresh token — without touching the session. The user stays logged
in throughout.

```python
async def _store_token(handler, user_info, tokens):
"""Called once the callback has verified the id_token."""
await save_refresh_token(
user_info["sub"], tokens.get("refresh_token"), tokens.get("scope", "")
)

oauth.register_consent_flow("files", {
"provider": "corporate",
"scopes": ["https://api.corp.example/auth/files"],
"auth_params": {"access_type": "offline", "prompt": "consent"},
"on_complete": _store_token,
})
```

Start it from a Tornado handler:

```python
await oauth.start_consent(handler, "files", next_url="/apps/me/back-here")
```

`start_consent` stores `"flow": "files"` in the state cookie; `handle_callback`
reads it back and hands the **raw token response** to `on_complete` instead of
calling `set_session`. The engine does not interpret `refresh_token` or
`scope` — what the extra scopes are for is entirely the application's business.

Two deliberate behaviours:

- An exception from `on_complete` is logged and swallowed, then the browser is
redirected anyway. It is mid-redirect from the provider; a bookkeeping
failure must not strand it on an error page.
- A state cookie naming an **unregistered** flow is refused with a 400. Falling
through would turn a consent round-trip into an unrequested login.

## Migrating off the Drive-specific API

The engine previously carried a Google-Drive-shaped path: `register_drive_token_hook()`,
`start_drive_consent()`, and a `state["flow"] == "drive"` branch in the
callback, with `drive.file`/`access_type=offline` hardcoded. That was one
application's requirement living in the framework. It is replaced by the
generic pair above.

| Removed | Replacement |
| --- | --- |
| `register_drive_token_hook(fn)` | `register_consent_flow(name, {...,"on_complete": fn})` |
| `start_drive_consent(handler, next_url)` | `start_consent(handler, name, next_url)` |
| hardcoded `state["flow"] == "drive"` | any registered flow name |
| hardcoded `drive.file` scope + `access_type=offline` | the flow's `scopes` / `auth_params` |

The hook signature changes from `(sub, email, refresh_token, scopes)` to
`(handler, user_info, tokens)`. Adapt an existing hook in place:

```python
oauth.register_consent_flow("drive", {
"provider": "google",
"scopes": ["https://www.googleapis.com/auth/drive.file"],
"auth_params": {
"access_type": "offline",
"prompt": "consent",
"include_granted_scopes": "true",
},
"on_complete": lambda handler, user_info, tokens: existing_hook(
user_info["sub"],
user_info["email"],
tokens.get("refresh_token"),
tokens.get("scope", ""),
),
})
```

The authorization request this produces is equivalent to the one
`start_drive_consent` used to build — same endpoint, same scopes, same
parameters and values. Only the order in which the query string encodes
them differs, which OAuth does not treat as significant.
25 changes: 24 additions & 1 deletion pyplet/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,21 @@ async def bootstrap_client(prefix, project_name, app_name, deps=()):
for dep in deps:
mip.install(dep)
else:
import micropip
try:
import micropip
except ModuleNotFoundError:
# micropip ships with Pyodide, but a reload after the app has
# already been used can restore a partial/inconsistent Pyodide
# package set from PyScript's IndexedDB cache (@pyscript.fs +
# the pyodide package cache), leaving micropip unregistered so
# `import micropip` raises ModuleNotFoundError at boot. Recover
# exactly as the error message itself advises: pull the package
# in via the Pyodide JS API and retry. This self-heals a stale
# or dirty cache without forcing the user to clear IndexedDB.
import pyodide_js

await pyodide_js.loadPackage("micropip")
import micropip

await micropip.install(list(deps))

Expand Down Expand Up @@ -501,6 +515,15 @@ async def bootstrap_client(prefix, project_name, app_name, deps=()):
):
await client_application.client_init()

# Drop the boot splash (server-rendered into #container) now that the app
# module has loaded and any client_init UI has mounted. Apps that replace
# #container's contents already removed it; this by-id removal also covers
# apps that append to #container, so the spinner never lingers. Gate on
# truthiness — getElementById yields a falsy JS null when already gone.
splash = js.document.getElementById("pyplet-boot-splash")
if splash:
splash.remove()

if (
client_application.__class__.websocket_client_loop
is not ClientApplication.websocket_client_loop
Expand Down
Loading