before configuring OAuth authentification
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user