Files
ctp_blogr/.omo/drafts/google-oauth-migration.md

444 lines
34 KiB
Markdown

---
slug: google-oauth-migration
intent: clear
review_required: false
classification: architecture
status: drafting
pending-action: write .omo/plans/google-oauth-migration.md
approach: <pending research + interview>
---
# Draft: google-oauth-migration
## Components (topology ledger)
<!-- Lock the SHAPE before depth. One row per top-level component that can succeed or fail independently. -->
<!-- id | outcome (one line) | status: active|deferred | evidence path -->
| id | outcome | status | evidence path |
|----|---------|--------|---------------|
| C1 | Google OAuth client (lib + registration + settings) wired into the app factory | active | `ctp_blogr/__init__.py:9-27` |
| C2 | Login/logout views replaced by authorize-redirect + callback | active | `ctp_blogr/views/default.py:37-76` |
| C3 | User identity model migrated from password hash to Google `sub` | active | `ctp_blogr/models/user.py:14-34`, `ctp_blogr.sqlite` `users` table |
| C4 | ACL/groupfinder admin mapping survives the identity change | active | `ctp_blogr/services/user.py:22-32`, `ctp_blogr/security.py:7-14` |
| C5 | Login UI + password forms retired | active | `ctp_blogr/templates/login.jinja2`, `ctp_blogr/forms.py:28-33` |
| C6 | Test strategy for auth without network calls to Google | active | `ctp_blogr/tests.py` |
## External findings - Google policy (developers.google.com, read 2026-10-05)
- **DECISIVE FINDING: Google's consent-screen test-user allowlist does NOT restrict access
for this app.** Google's own production-readiness table states, for Publishing Status
`Testing` / External: "Only users explicitly added to the test user allowlist can access the
app (limited to a hard cap of 100 test users). **Exception: If the app only requests basic
identity scopes (`openid`, `email`, `profile`), any user can access without being on the
allowlist.**" -> With exactly the scopes this blog needs, the allowlist is bypassed. The
restriction MUST be enforced in application code. This is now a verified fact, not a
precaution, and it is the reason the access-control question is unavoidable.
(Corroborated by a StackOverflow report of non-test-users successfully completing the flow.)
- `scope`: "The list must include the `openid` value and then include the `profile` value, the
`email` value, or both." Google states a basic request should be `openid email`. So
`profile` is only needed for display name / picture -> **use `openid email` only**; requesting
`profile` adds PII for no functional gain in this app.
- `nonce` is listed as **(Required)** in Google's OIDC authentication-request parameters:
"A random value generated by your app that enables replay protection." The plan MUST generate
a nonce on the authorize step and validate it against the `id_token` claim on callback.
- `response_type=code` for web server applications; the implicit flow (`token`, `id_token`,
`token id_token`) is described as "strongly discouraged" and "forbidden in OAuth 2.1".
OAuth 2.1 guidance: "PKCE ... Must be used to secure the authorization code flow."
- PKCE for a confidential web-server client: not documented as mandatory by Google for this
flow, but recommended by the wider ecosystem ("confidential clients use PKCE in addition to
their client secret"). `code_challenge_methods_supported` is exposed via discovery.
-> Plan SHOULD use PKCE; it is cheap and is the OAuth 2.1 direction.
- `redirect_uri` "must exactly match one of the authorized redirect URIs for the OAuth 2.0
client, which you configured in your client's Cloud Console Clients page."
- `client_secret` is still expected for a confidential server-side client.
- Refresh tokens: for an app requesting only `openid`/`email`/`profile`, the 7-day Testing-mode
refresh-token expiry does NOT apply. This app needs no refresh token at all for sign-in.
- `hd` is a supported authorization parameter (appears in Google's own example:
`...&authuser=0&hd=example.com&prompt=consent`) -> the mechanism for a Google Workspace
domain restriction, if that is the chosen access model.
- `email` is only present "if you included the email scope"; `email_verified` accompanies it.
## Library facts (PyPI API, queried 2026-10-05)
| package | version | requires-python | license | latest release |
|---|---|---|---|---|
| authlib | 1.8.0 | >=3.10 | BSD-3-Clause | 2026-08-30 |
| google-auth | 2.59.1 | >=3.10 | Apache 2.0 | 2026-09-30 |
| google-auth-oauthlib | 1.5.0 | >=3.10 | Apache 2.0 | 2026-09-29 |
| oauthlib | 4.0.0 | >=3.9 | BSD-3-Clause | 2026-09-28 |
| pyjwt | 2.15.1 | >=3.9 | - | 2026-09-28 |
| requests-oauthlib | 2.0.0 | >=3.4 | ISC | 2024-03-22 |
| pyramid_oauthlib | 1.0.0 | >=3.7 | - | 2022-09-17 |
| pyramid-google-auth | HTTP 404 | - | - | NOT ON PyPI |
- Every serious candidate requires Python >=3.9/3.10, so all are compatible with this repo's 3.14.
- `pyramid-google-auth` is **no longer on PyPI at all** -> definitively ruled out.
- `pyramid_oauthlib` last released 2022-09-17 (4 years stale) -> treat as unmaintained.
- `requests-oauthlib` last released 2024-03-22 (2+ years) -> aging.
- `authlib`, `google-auth*`, `oauthlib`, `pyjwt` were all released within the last ~6 weeks of
2026-09-28/30 -> all actively maintained.
- Authlib additionally pulls in `joserfc` for JWT handling.
## Open assumptions (announced defaults)
<!-- Record any default you adopt instead of asking, so the user can veto it at the gate. -->
<!-- assumption | adopted default | rationale | reversible? -->
| assumption | adopted default | rationale | reversible? |
|---|---|---|---|
| Session store for `state` + PKCE verifier + nonce | keep the existing `SignedCookieSessionFactory`; no `pyramid_beaker` | the three values total well under 200 bytes against a ~4KB cookie budget, and the cookie is already the app's flash-message store; adding a server-side session backend is a second dependency in an already-shared venv. Note the cookie is signed but NOT encrypted, so these values are readable by the client - acceptable for a nonce/state/verifier, which are single-use and short-lived | yes |
| Password login | removed entirely, no fallback | the request says "remplacer"; a dual path doubles the auth surface and contradicts the goal | yes |
| `profile` scope | not requested | this app shows no display name or avatar; `openid email` is the documented minimum | yes |
| Refresh token / `access_type=offline` | not requested | sign-in only; no Google API calls are ever made | yes |
| Explicit `logout` revocation | local session logout only | see question; the app holds no Google-side token to revoke | yes |
| Tidy known bug in touched code | leave the `views/default.py:142` `delete(...)` `NameError` alone unless it sits on a line the migration must edit anyway (bugfix rule: no refactor while migrating) | avoids scope creep | yes |
## Findings (cited - path:lines)
- Auth is `AuthTktAuthenticationPolicy(hashalg='sha512', timeout=36000)` with `groupfinder`
callback, secrets hardcoded in the app factory - `ctp_blogr/__init__.py:13-21`.
- Session is `SignedCookieSessionFactory('hZug2zPt7hT2MZ')` (signed, NOT encrypted, ~4KB) -
`ctp_blogr/__init__.py:13`.
- ACL is 3 lines: `view` -> `Authenticated`, `manage` -> `group:administrators`, `DENY_ALL` -
`ctp_blogr/security.py:9-12`.
- Admin is NOT a DB flag: `groupfinder` hardcodes `userid == 'admin'` -
`ctp_blogr/services/user.py:26-27`. With OAuth the userid becomes a Google subject/email, so
this mapping MUST change or nobody keeps `manage`.
- Passwords are passlib `custom_app_context` hashes; `verify_password` auto-upgrades cleartext -
`ctp_blogr/models/user.py:21-30`. `password` is `nullable=False` (`users.password`) - dropping
the column in SQLite requires a table rebuild, not `ALTER TABLE DROP COLUMN` on old versions.
- `UserCreateForm` has `password` + `confirm` and enforces a 6-char minimum in the view -
`ctp_blogr/forms.py:28-33`, `ctp_blogr/views/default.py:116-118`.
- Non-admin users are pointed at `/user_edit` as "Modifier le mot de passe" -
`ctp_blogr/templates/layout.jinja2:43-49`. That whole self-service flow dies with OAuth.
- `UserService.delete` exists; `views/default.py:142` calls an undefined bare `delete(...)`
(known NameError, already recorded in AGENTS.md).
- Committed dev DB: `ctp_blogr.sqlite`, users `admin` (id 1) and `phuoc` (id 2), 99 entries.
No `alembic.ini` / `migrations/` in repo though `alembic_version` row exists
(rev `b8f8216d72c7`) -> schema changes are hand-written `ALTER TABLE` (AGENTS.md "Database").
- `pyramid_tm` with `tm.manager_hook = 'pyramid_tm.explicit_manager'` commits at the end of a
successful request - `ctp_blogr/models/__init__.py:61-64`. A user row created inside the OAuth
callback GET is committed by that implicit commit; there is no explicit `commit()` anywhere.
- `pyramid_retry` with `retry.attempts = 3` is included - `ctp_blogr/models/__init__.py:67`,
`development.ini:19`. Retrying the OAuth callback is a hazard to pin down.
- No linter/type-checker/CI/pre-commit exists. Only `python -m pytest` (2 tests, both trivial).
- `coverage` is broken (`.coveragerc` `source = pyramid_blogr`).
- **`request.authenticated_userid` is load-bearing in 11 places across 6 files**, and it is
more than a boolean:
- 4 comparisons against the literal `'admin'` -> `services/user.py:26`,
`views/default.py:93`, `templates/layout.jinja2:43`, `templates/user_edit.jinja2:45`.
With OAuth the userid stops being `'admin'`, so ALL FOUR break at once.
- it is WRITTEN TO THE DATABASE: `views/blog.py:64` (`entry.creator`),
`views/blog.py:75` (`entry.editor`), both `Unicode(50)` columns. 99 existing rows already
hold `'admin'` / `'phuoc'`, so post-migration the byline has mixed provenance.
- it is USED AS A URL SEGMENT: `templates/layout.jinja2:46`
`request.route_url('user_edit', name=request.authenticated_userid)`. An email-based
identity contains `@` and `.` and must survive Pyramid's route_url encoding.
- it is the ANONIMITY GATE: `services/blog_record.py:16,30,39` test
`request.authenticated_userid == None` to decide the published-only filter. If the new
session identity is ever not populated, this fails OPEN (site looks merely "empty"),
and no existing test would catch it.
- **Open-access regression risk (highest severity finding).** Today only the two
pre-provisioned rows can log in. After OAuth, ANY Google account holder can authenticate.
`templates/blog.jinja2:7` renders "[ Modifier ]" for ANY authenticated user, and
`routes.py:13` `user_edit` / `views/default.py:79` `users` are the only guarded pages.
So without an explicit allowlist or Workspace-domain restriction, the migration would
expose post editing to the public internet. This must be an explicit owner-decision.
- `templates/login.jinja2:8-20` posts `username`, `password`, `came_from` with
`name="form.submitted"`; the whole form is replaced by a redirect-to-Google button.
- `templates/user_edit.jinja2:26-38` renders the password/confirm block; the whole
non-admin self-service path ("Modifier le mot de passe") dies with OAuth.
- `templates/users.jinja2:7,23` wires admin user creation through
`request.route_url('user_edit', name='0')` and `name=entry.name`; under OAuth accounts are
provisioned at first login, so "Nouvel utilisateur" has no meaning.
- `templates/users.jinja2:27` calls `entry.last_logged.strftime(...)` with no NULL guard; the
model default is a Python-side `default=`, so any row inserted by raw SQL (i.e. by the
migration) and not through the ORM can render as a 500.
- **No OAuth-capable library is installed in the shared venv** - no `authlib`,
`google-auth`, `oauthlib`, `pyjwt`, `jose`, or `pyramid_beaker`. The plan necessarily adds a
`pip install` into a venv that is shared with a second project (AGENTS.md "Environment").
- `ctp_blogr/views/__init__.py` and `ctp_blogr/services/__init__.py` are both empty (0 bytes).
## External findings - Authlib API (from official Authlib docs, read 2026-10-05)
- **OpenID Connect discovery is supported and is the clean registration path.** Authlib can
take `server_metadata_url` and derive `authorize_url`, `access_token_url` and `jwks_uri`
automatically:
```python
oauth.register('google', client_id=..., client_secret=...,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid email profile'})
```
Without a discovery endpoint the developer MUST supply `authorize_url`, `access_token_url`
and `jwks_uri` by hand. -> use discovery.
- **CRITICAL RISK: Authlib's high-level API is Flask/Django-only; Pyramid is NOT integrated.**
`authorize_redirect(request, redirect_uri=None, **kwargs)` and the whole state mechanism
live in `authlib/integrations/base_client/framework_integration.py` and
`authlib/integrations/django_client/apps.py`. `set_state_data` /
`clear_state_data` / `save_authorize_data` store state under a `_state_{name}_{state}` key
in a `session` object with an `exp` field. There is no Pyramid framework integration, so
**state generation, storage, and validation are the application's own responsibility.**
The framework-agnostic Authlib entry point is the lower-level `requests_client.OAuth2Session`.
=> the plan must hand-write the state plumbing that Flask/Django users get for free, and
this is the single strongest argument for the hand-rolled `requests` + `pyjwt` option.
- `authorize_redirect` internally does `create_authorization_url(...)` then
`save_authorize_data(request, redirect_uri=redirect_uri, **rv)`; the returned `rv` is where
`url`, `state` and the PKCE `code_verifier` live. Without a framework integration the app
must persist those values itself (this app's signed-cookie session is the only store).
- PKCE: RFC 7636 `code_challenge` is enabled by registering `code_challenge_method` in
`client_kwargs`.
- Token response: `token = oauth.google.authorize_access_token()`; Authlib parses the
`id_token` and exposes claims as `token['userinfo']` so the userinfo endpoint need not be
called. Docs say framework integrations do this automatically "if configurations are
correct" - the Pyramid path must be verified against the `requests_client` API before the
plan relies on `token['userinfo']`, with a documented fallback to decoding the `id_token`.
- Raw ID-token validation (independent of the above) is
`authlib.oidc.core.CodeIDToken` + `joserfc.jwt.decode(id_token, keys)` where keys are fetched
from `https://www.googleapis.com/oauth2/v3/certs`, then `claims.validate()`.
-> Authlib pulls in `joserfc` for JWT; relevant to the dependency-weight trade-off.
- `authorize_access_token()` is shown taking no argument in the Flask example because
`redirect_uri` was persisted in the state data. In Pyramid the app must pass `redirect_uri`
explicitly and it must byte-match the value sent to the authorize step.
- **Authlib Loginpass (pre-configured social providers incl. Google) is Flask and Django
only** - it is not usable from Pyramid. Rules out the "use the batteries-included Google
connector" option.
## Decisions (with rationale)
- Plan only; no product-code edits in this session (planner role).
- **Library: Authlib 1.8.0** (owner decision, 2026-10-05). Active (2026-08-30), Python >=3.10,
BSD-3. Use OIDC discovery (`server_metadata_url=
https://accounts.google.com/.well-known/openid-configuration`) so `authorize_url`,
`access_token_url` and `jwks_uri` are not hand-written. `pyramid-google-auth` is not on PyPI
and `pyramid_oauthlib` is 4 years stale, so both are ruled out.
- **Access control: database allowlist** (owner decision). The Google consent-screen test-user
allowlist provably does not apply to `openid email`, so the app must check, on callback, that
the authenticated email matches a pre-provisioned `users` row. This preserves today's
semantics exactly (only pre-provisioned accounts can sign in) and keeps `/users` as the
admin surface for granting and revoking access.
- **Existing accounts: one-shot email binding** (owner decision). `admin` (id 1) and `phuoc`
(id 2) are bound to their real Google addresses by a one-shot `UPDATE` in
`ctp_blogr.sqlite`. No first-login claim window is opened, so there is no period in which the
public can self-register.
- **VERIFIED LOCALLY: Pyramid already ships the auth policy we need, no new dependency.**
`pyramid.authentication.SessionAuthenticationPolicy(prefix='auth.', callback=...)` and
`SessionAuthenticationHelper` exist in Pyramid 2.1 and expose exactly the same three methods
as the current `AuthTktCookieHelper`: `remember`, `forget`, `authenticated_userid`. Therefore
- `AuthTktAuthenticationPolicy(callback=groupfinder)` -> `SessionAuthenticationPolicy(callback=...)`,
- the existing `remember(request, username)` / `forget(request)` calls in
`views/default.py:54,58,74` keep working unchanged,
- `Authenticated` still lands in `effective_principals`, so `security.py`'s ACL is untouched,
- and the hardcoded auth-ticket secret at `__init__.py:15` disappears.
Authlib is the only library added to the venv.
- **What `remember()` stores: the user's email.** Chosen because it is the identity that makes
the other 11 `authenticated_userid` call sites keep working: `layout.jinja2:46` routes by
`name=authenticated_userid`, and `views/blog.py:64,75` already write this value into the
`Unicode(50)` `entries.creator` / `entries.editor` columns (today holding `'admin'` /
`'phuoc'`). Cost, stated plainly: the signed cookie session is signed but NOT encrypted, so
the email is readable by the client. Acceptable for a public blog author's address; a numeric
`users.id` would avoid it but would break `layout.jinja2:46` and the byline columns.
- The 4 literal `'admin'` comparisons must be replaced by data, not by a new literal:
`services/user.py:26` (groupfinder), `views/default.py:93`,
`templates/layout.jinja2:43`, `templates/user_edit.jinja2:45`.
- `nonce` is REQUIRED by Google's OIDC spec -> generate on the authorize step, store in the
session, compare against the `id_token` claim on callback.
- PKCE included (`code_challenge_method=S256` + stored `code_verifier`) even though not
documented as mandatory for a confidential web-server client: cheap, and it is the direction
of OAuth 2.1, which Google itself links from its own guidance.
## Scope IN
## Scope OUT (Must NOT have)
## Open questions
- ~~BLOCKING DATA INPUT~~ **RESOLVED 2026-10-05.** The two Google addresses, supplied by the
owner: `admin` (id 1) -> `phuoc@caotek.fr` (keeps the `manage` role);
`phuoc` (id 2) -> `ctphuoc@gmail.com` (authenticated, no `manage`). These are two DISTINCT
people, so each `users` row maps to exactly one Google identity and no merge is needed.
- `caotek.fr` MX records point at Google (`aspmx.l.google.com` pref 1 plus the canonical
`alt1/2/3` + `aspmx2/3` set) while NS and git are at OVH, so the domain is PROBABLY Google
Workspace. That would have made an `hd=caotek.fr` restriction viable, but it is not proven
(Google MX does not prove the accounts are Workspace accounts). The chosen DB allowlist is
strictly stronger and has no external dependency, so this is recorded as a valid alternative
that was considered and declined - not as a fallback.
- Where the OAuth client_id / client_secret live, given both `development.ini` and
`production.ini` are tracked in git. Not yet asked; will be defaulted in the plan to
PasteDeploy `%(ENV_GOOGLE_CLIENT_ID)s` interpolation with a documented fallback, so no
secret is ever committed. Flagged for veto at the gate.
## Metis gap analysis - 2026-10-05 (empirical, verified against installed sources)
Metis read the installed Pyramid source and booted the real app. It reported 10 blockers and
12+ majors. Several invalidate parts of MY OWN approach. The following are corrections that
change the plan, not just the todos.
### Corrections to my own design (must be folded in)
- **C1. The groupfinder must NOT query the database.** `pyramid_tm/__init__.py` sets
`annotate_user = asbool(settings.get('tm.annotate_user', True))` -> defaults **True**, and
lines ~136-139 call `request.authenticated_userid` INSIDE `manager.begin()`. With a DB-backed
groupfinder, EVERY request performs a DB SELECT inside the transaction block, and any DB error
500s every route including `/login` and the OAuth callback itself. Metis reproduced the
traceback. **Revised design: the allowlist stays in the DB (it is the login gate, checked once
in the callback), but the GROUP mapping must be config-only, not DB.** `groupfinder` reads an
`auth.admin_emails` list from settings. This also makes "admin is data, not a literal" concrete
and keeps revocation immediate. `CallbackAuthenticationPolicy` calls the callback twice per
request (once in `authenticated_userid`, once in `effective_principals`), so a config-only
callback is essentially free.
- **C2. "Unknown email -> 403" is UNACHIEVABLE as I wrote it.** `forbidden_view_config` on
`login` (`views/default.py:38`) catches `HTTPForbidden` and renders `login.jinja2` with status
**200**. Verified on the real app: anonymous `/users` returns 200 plus the login form. So a
rejected OAuth user would be shown the dead password form at 200. **Revised: the callback must
set `request.response.status_int = 403` explicitly and render a French "acces refuse" template
WITHOUT raising `HTTPForbidden`, and the `forbidden_view_config` must be replaced rather than
inherited.** All acceptance criteria asserting 403 must be rewritten to assert on the explicit
status plus content, and the AGENTS.md "200 not 403" note must be updated.
- **C3. The session factory must be reconfigured, and I never mentioned it.**
`SignedCookieSessionFactory('hZug2zPt7hT2MZ')` at `__init__.py:13` has default `timeout=1200`
(20 min) against the current `AuthTktAuthenticationPolicy(timeout=36000)` (10 h). A Google
consent screen plus Google sign-in plus 2FA routinely exceeds 20 min, so `state`/`code_verifier`/
`nonce` would expire mid-flow. The timeout must be raised explicitly.
- **C4. `request.session.renew()` DOES NOT EXIST in Pyramid 2.1**, and neither do
`SessionRenew` / `SessionInvalidate`. The only primitive is `invalidate()`, which is literally
`self.clear()` carrying the upstream comment `# XXX probably needs to unset cookie`. My brief
asked the wrong question. There is no server-side session id to rotate, so classic session
fixation is structurally impossible; the real requirement is to DELETE the `state`/
`code_verifier`/`nonce` keys after the callback so the values do not survive client-side.
- **C5. My test strategy does not work as written.** `BaseTest` in `ctp_blogr/tests.py` builds a
`testing.DummyRequest`, not a WSGI app, so WebTest cannot drive it. Worse,
`BaseTest.init_database()` is DEFINED BUT NEVER CALLED by either existing test, so no table is
ever created. "WebTest on :memory: using the existing BaseTest fixture" is impossible. The plan
needs its own test-app harness that builds a real WSGI app with a stubbed OAuth client and an
in-memory engine.
- **C6. `create_authorization_url` returns `(uri, state)` and does NOT return `code_verifier`.**
The app must generate it with `generate_token(48)`, pass it in, and save it itself. My brief
implied Authlib hands these back.
### Blockers Metis raised that are not yet in the todos
- **B1. `authlib` is not installed and not in `setup.py`**, and it drags in `joserfc`. Installing
into the shared parent venv is exactly what AGENTS.md warns against. Needs an explicit
dependency-manifest + install step with the shared-venv risk called out.
- **B5. The OAuth callback CAN be retried.** `pyramid_tm` tags exceptions as retryable via
`transaction.Transaction.isRetryableError`; SQLite "database is locked" is a transient error,
so `pyramid_retry` re-runs the callback, re-exchanging the same authorization code ->
`invalid_grant` -> the user sees a failed login. Code-exchange idempotency must be addressed,
and the callback should be made non-retryable.
- **B6. `user_edit` has `permission='view'`, NOT `manage`, and has NO ownership check.** The only
`authenticated_userid` comparison in the whole function is `== 'admin'` for the return URL.
Any authenticated allowlisted user can open `/user_edit/<any-email>` and rewrite that row, or
`/user_edit/0` to create one. Live today with 2 users; becomes email enumeration plus
horizontal privilege once the allowlist grows.
- **B7 + M8. `verify_password` (`models/user.py:29`) is the ONLY writer of `last_logged`.**
Removing it removes the only writer, and `templates/users.jinja2:27` calls
`entry.last_logged.strftime(...)` with no NULL guard -> verified `UndefinedError`. The callback
must set `last_logged` explicitly, or `/users` 500s.
- **B8. `user_edit.jinja2:27-28,36-37` will `UndefinedError`** once the WTForms `password`/
`confirm` fields are removed, because Jinja2's default `Undefined` raises on attribute access.
Verified.
- **B10. `passlib` is imported at top level in `models/user.py:11`.** Removing the dependency
without removing that import breaks the whole app at import time.
- **M2. There is zero CSRF protection today** (no `csrf` string anywhere in the repo), and the
callback adds none. Pyramid 2.1 DOES ship `session.new_csrf_token()` / `get_csrf_token()`
(`_csrft_`, 40 hex chars) on the cookie session, so no `pyramid_csrf` package is needed - but
nothing wires it automatically; each view must render and compare manually. `state` is
sufficient for the callback specifically (a GET, single-use, bound to the pre-auth session).
`logout` is a GET that mutates session state, so it is login-CSRF-able.
- **M3. The read filter fails CLOSED** (`authenticated_userid == None` -> anonymous -> only
`status == 'publié'`), which is the safe direction. But `by_id` has no filter at all, and
`blog_search`/`by_criteria` leaks draft titles and bodies to any authenticated user via `LIKE`.
- **M4. Cookie size is NOT a blocker** - a correction to my own framing. Measured: identity +
state + code_verifier + nonce serializes to **324 bytes**; with 10 French flash messages, 662.
Pyramid raises a hard `ValueError` at **4064 bytes**, so headroom is 3740. The only growth
vector is unbounded flash accumulation.
- **M5. `set_on_exception=True` + `pyramid_retry`** means the session `Set-Cookie` is emitted even
for error responses, and a retried request can emit several.
- **M6. The cookie is not hardened for an identity-bearing value**: `secure=False`, `httponly=False`,
and the secret is 16 characters while `hashalg='sha512'` wants at least 64.
`production.ini:29` declares `url_scheme = https` yet the cookie still carries no `Secure` flag,
and `httponly=False` plus third-party jQuery/Bootstrap CDNs in `layout.jinja2:14-15,106-107`
means any XSS reads the identity straight out of the cookie.
- **M7. `entries.creator` / `entries.editor` are `Unicode(50)`** and are written directly from
`request.authenticated_userid` at `views/blog.py:64,75` with no length guard.
- **M9. Admin-as-data had no defined read path.** Templates cannot call the groupfinder; the
fix is `'group:administrators' in request.effective_principals` at all four sites. Where the
flag lives was undecided; C1 settles it as settings, not a new column.
- **M12. The test seam must be named**, and tests must disable `pyramid_retry` or single-shot side
effects such as state-clearing become non-deterministic.
### Correction to a claim I made in the brief
- Authlib 1.8 does NOT ship integrations for Flask and Django only - there is also
`authlib.integrations.starlette_client`. The conclusion that there is **no Pyramid integration**
still holds, which is the part that matters.
### Second pass - M13, the minors that actually bite, and the 22 acceptance criteria
- **M13. The `NameError` at `views/default.py:142` is FREE to fix**, because `user_edit` must be
edited anyway: line 93 (`'admin'` literal), 116-117 (`len(form.password.data) < 6`), 127 and
137 (`set_password`), 98-102 and 119-131 (create-user branch). `UserService.delete` already
exists at `services/user.py:18`. **But the trap:** `user_edit.jinja2:45` gates the delete
button on `request.authenticated_userid == 'admin'`; if that literal is swapped for the
`effective_principals` check from M9, **the button still renders for admins and the
`NameError` survives the migration**. Fix line 142 in the same change or ship a 500.
**Second-order:** `UserService.delete` is
`query.filter(User.id == id).delete(synchronize_session=False)` with **no FK cascade** from
`entries.creator`/`entries.editor`, so deleting a user orphans those strings. With emails as
the identity, that is now **PII stranded in an orphaned column** and it collides directly with
Decision 3's accepted cost.
- **`hd` is a TRAP - do NOT set `hd=caotek.fr`.** It is a *hosted-domain* hint meaning "a
Workspace account on this domain", and `ctphuoc@gmail.com` is a **personal Gmail account**.
Setting it locks out the second user and contradicts Decision 5. The Workspace-admin-blocks-the-
app-app-wide observation is correct, and it is exactly why `hd` is not a control here. Omit it.
- **The call-site count in my brief was wrong: it is 15, not 11.** 7 in Python
(`services/blog_record.py` x3, `views/blog.py` x2, `views/default.py` x2) and 8 in templates
(`layout.jinja2` x4, `blog.jinja2` x2, `home.jinja2` x1, `user_edit.jinja2` x1). Plan for 15.
- **JWKS keys must be fetched AND CACHED by the app.** With the bare Authlib client there is no
automatic fetch: `CodeIDToken` + `jwt.decode(id_token, keys)` needs the keys pulled from
`jwks_uri` ourselves. An unbounded refetch per login is a latency and rate-limit problem, so the
cache and its TTL have to be decided, not left implicit.
- **`request.session.get_csrf_token()` is a lame token**: one long-lived random value per session
under key `_csrft_`, not per-request, and there is **no automatic comparison helper** - every
view must compare by hand. It also writes to the session on first read.
- **Cookie secret is 16 characters against `hashalg='sha512'`**, which wants at least 64. Since
the cookie now *is* the identity, that secret is load-bearing and must be lengthened.
- **Branch and rollback are UNSETTLED and are a prerequisite, not a detail.** There is one commit
on `master` with `main` and `origin/*` at the same commit, and this migration performs a
**destructive table rebuild on a committed database file**. An explicit branch decision plus a
documented restore path for `ctp_blogr.sqlite` is required *before* touching it. Do not commit
unless asked.
- Passlib hashes stay in git history forever even after the destructive rebuild discards them
from the working tree - belongs in the accepted-costs note.
- `.coveragerc` still says `source = pyramid_blogr`, so coverage collects nothing. **Coverage must
not be used as an acceptance gate** for this migration; the number would be fiction.
- Harmless: `logout` has no permission and `forget()` on an empty session is a no-op.
### The three items Metis left undecided - now settled
1. **"Admin is data"** -> resolved by C1: `groupfinder` reads `auth.admin_emails` from **settings**,
never the DB, because B3 forbids a DB-backed callback inside the `pyramid_tm` tween. The
database keeps only the login allowlist.
2. **"The allowlist is the primary control"** -> yes, confirmed: the DB allowlist gates sign-in
once, in the callback. It is not a per-request check, so it cannot be a DB callback.
3. **"403 on unknown email"** -> achievable only as an explicit
`request.response.status_int = 403` plus a French rejection template, **without raising
`HTTPForbidden`** (which the `forbidden_view_config` on login would swallow into a 200).
Metis supplied **22 numbered acceptance criteria**, grouped by boot, session policy, callback,
access control, templates/removals, schema and regression. They are transcribed into the plan's
Verification section rather than restated here, together with its 6-phase sequencing.
## Approval gate
status: approved
approved-on: 2026-10-05
approval-evidence: >-
The owner answered the single blocking data input (the two Google email addresses) after the
brief was presented. Per the gate mechanics, "answering the open ambiguities" constitutes
approval to write the plan. Authorization covers writing `.omo/plans/google-oauth-migration.md`
ONLY - it is not authorization to implement.
approach: >-
Swap AuthTktAuthenticationPolicy for Pyramid's built-in SessionAuthenticationPolicy (same
three-method helper interface, so no new auth dependency and the hardcoded auth-ticket
secret disappears). Add Authlib 1.8.0 as the only new library, registered for Google via
OIDC discovery, with OIDC state + PKCE (S256) + nonce generation, storage and validation
written by the app because Authlib has no Pyramid integration. Login becomes a redirect to
Google; a new callback route exchanges the code, validates the id_token, checks the email
against a database allowlist, provisions nothing implicitly, and remembers the identity in
the session. Replace the 4 literal 'admin' comparisons with a data-driven role. Remove the
password column, the passlib dependency, the password/confirm form fields, the self-service
password-change page, and the admin "create user" flow, since accounts become
pre-provisioned by email. Hand-write the ALTER TABLEs against ctp_blogr.sqlite and warn that
no migrations exist. Add WebTest coverage that never touches the network.
pending-action: write .omo/plans/google-oauth-migration.md
test-strategy: >-
tests-after, agent-executed. WebTest drives the routes against a `sqlite:///:memory:` engine
built by the existing `BaseTest` fixture. The Google token exchange and JWKS fetch are
injected behind a small seam so no test performs network I/O. Per-todo QA covers both the
happy path and the failure paths that matter (unknown email -> 403, missing/mismatched
state, missing/mismatched nonce, `error=access_denied`). Announced as a default, not asked.