# google-oauth-migration - Work Plan ## TL;DR (For humans) **What you'll get:** Password login is gone. Visitors sign in with a Google account, and only the two email addresses you already have in the database are allowed in. Signing in as phuoc@caotek.fr keeps admin powers; signing in as ctphuoc@gmail.com gets you in without them. **Why this approach:** The app keeps its own list of permitted emails and checks it once, at sign-in, so Google's "test user" loophole cannot be used to walk in. Authorization no longer decides who is an admin by comparing a name against the word "admin"; it reads a configured list instead. Sign-in state is protected with Google's recommended anti-hijacking checks. **What it will NOT do:** It will not create accounts for strangers, ask Google for any extra information about you, add a server-side session system, or introduce a database migration tool that the project does not have. **Effort:** Large **Risk:** High - it replaces the entire sign-in mechanism and rebuilds a database table inside a database file that is tracked in version control. **Decisions to sanity-check:** Your admin rights move from a hardcoded name to a list you edit in a config file; the sign-in screen timeout is lengthened so slow Google sign-ins are not thrown away; a broken edit-user page and a crash on the user list are fixed along the way because they sit in the code being changed. Your next move: approve, or run a high-accuracy review. Full execution detail follows below. --- > TL;DR (machine): Large/high-risk Pyramid 2.1 auth swap to Google OIDC via Authlib, DB allowlist > gate, destructive SQLite users-table rebuild, 14 todos in 5 waves + F1-F4. ## Scope ### Must have - **Google OAuth 2.0 / OIDC sign-in replacing password login entirely.** Login becomes a redirect to Google; there is no local credential path left in the codebase. - **Authlib 1.8.0 as the only new library**, driven by Google's OpenID discovery document at `https://accounts.google.com/.well-known/openid-configuration` so `authorize_url`, `access_token_url` and `jwks_uri` are never hand-written. It is a requests-based client (`authlib.integrations.requests_client.OAuth2Session`); Authlib ships integrations for Flask, Django and Starlette but **none for Pyramid**, which is why the app owns its own `state` / PKCE / `nonce` handling. **The app fetches the discovery document itself** and passes the resolved `authorization_endpoint`, `token_endpoint` and `jwks_uri` into the client: passing `server_metadata_url=` to `OAuth2Session` only stores inert metadata, because `load_server_metadata` exists solely on the Starlette-style `sync_app` / `async_app` clients. The document is cached alongside the JWKS rather than refetched per sign-in. - **A named, registry-registered collaborator for the Google exchange** - not ad-hoc monkeypatching in tests. It exposes `create_authorization_url(...)` and `fetch_claims(...)`, and it **fetches and caches the JWKS** itself, because the bare client does not: `CodeIDToken` + `jwt.decode` require the app to pull keys from `jwks_uri`, and an unbounded refetch on every sign-in is a latency and rate-limit problem. Cache TTL is a decision of this plan, not left implicit. - **App-owned `state`, PKCE and `nonce` handling.** `state` is generated, persisted in the session and **consumed (deleted) before the token exchange is attempted**; `code_verifier` is generated by the app with `generate_token(48)` because `create_authorization_url` returns only `(uri, state)` and never hands the verifier back; `nonce` is generated, persisted, compared against the `id_token` claim, then deleted. - **`nonce` is REQUIRED** by Google's OIDC request parameters. - **PKCE with `code_challenge_method=S256`**, the `code_verifier` persisted between the two hops. - **A single, non-replayable code exchange.** `pyramid_tm` tags exceptions as retryable, so a SQLite "database is locked" transient error can make `pyramid_retry` re-run the callback and re-exchange an already-used authorization code, which fails with `invalid_grant` and shows the user a broken login. The callback must be non-retryable and `fetch_claims` must be called **at most once** per request even with retries enabled. - **Auth policy swap to Pyramid's built-in `SessionAuthenticationPolicy` + `SessionAuthenticationHelper`**, replacing `AuthTktAuthenticationPolicy`. Pyramid 2.1 ships both with the same three-method interface as `AuthTktCookieHelper`, so `remember()`/`forget()` call sites keep working and `security.py` is untouched. The hardcoded auth-ticket secret is removed. - **The session factory is explicitly reconfigured - it is a first-class part of this change, not an afterthought.** `SignedCookieSessionFactory('hZug2zPt7hT2MZ')` defaults to `timeout=1200` (20 minutes) while the outgoing `AuthTktAuthenticationPolicy` used `36000` (10 hours). A Google consent screen, a Google sign-in and a second factor routinely exceed 20 minutes, so the OAuth values would expire mid-flow. The factory must be constructed with an explicit, longer `timeout`, a secret of at least 64 characters for `hashalg='sha512'` (it is currently 16), and `httponly=True` in both environments plus `secure=True` under production settings. Today the cookie carries `secure=False`, `httponly=False` even though `production.ini` declares `url_scheme = https`; once the cookie *is* the identity, third-party CDN scripts in `layout.jinja2:14-15,106-107` can read it via XSS. - **No session-id rotation is attempted, because the API does not exist.** `request.session.renew()` is **not** part of Pyramid 2.1, and neither are the `SessionRenew` / `SessionInvalidate` exceptions. The only primitive is `invalidate()`, which is literally `self.clear()` carrying the upstream comment `# XXX probably needs to unset cookie`. There is no server-side session id, so classic session fixation is structurally impossible; the real requirement is to **delete the `state` / `code_verifier` / `nonce` keys after the callback** so they do not survive client-side. - **`remember()` stores the user's email** as `authenticated_userid`, which keeps all **15** existing `request.authenticated_userid` call sites working - 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) - including `layout.jinja2:46` (routes `/user_edit/{name}` by that value) and `views/blog.py:64,75` (already write it into `entries.creator` / `entries.editor`). - **A database allowlist as the mandatory access control, checked exactly once, in the callback.** An email that does not match a pre-provisioned `users` row is rejected and no row is created. This is required, not defensive: Google's own production-readiness guidance states the consent-screen test-user allowlist is bypassed when an app requests only basic identity scopes. - **The allowlist check must NOT become the group lookup.** `pyramid_tm` defaults `tm.annotate_user` to `True` and then calls `request.authenticated_userid` *inside* `manager.begin()`. A database-backed groupfinder therefore runs a SELECT on **every single request, inside the transaction**, and any database error turns every route - including `/login` and the OAuth callback itself - into a 500. This was reproduced against the real app. - **Admin rights become configured data, not a literal and not a database lookup.** All 4 sites comparing to the literal `'admin'` are replaced by `'group:administrators' in request.effective_principals`: `services/user.py:26`, `views/default.py:93`, `templates/layout.jinja2:43`, `templates/user_edit.jinja2:45`. The mapping from email to admin comes from an `auth.admin_emails` list in settings, so it survives a session loss, takes effect immediately on edit, and costs no query. Templates cannot call a groupfinder, which is why the `effective_principals` check is the correct template-side expression. - **One-shot binding of the two existing accounts, with no first-login claim window:** `admin` (id 1) -> `phuoc@caotek.fr` (retains `manage`); `phuoc` (id 2) -> `ctphuoc@gmail.com` (authenticated, no `manage`). Two distinct people, so each row maps to exactly one identity. The Google `sub` is stored on first successful sign-in and matched first on later sign-ins, because `sub` is Google's stable identifier and email is mutable. - **Removal of the password surface:** the `users.password` column, the `passlib` dependency, the top-level `import passlib` in `models/user.py:11` (**removing the dependency without removing that import breaks the entire app at import time**), `User.verify_password` / `set_password`, the `password` / `confirm` WTForms fields and their length check at `views/default.py:116-117`, the non-admin self-service password path, and the admin "Nouvel utilisateur" create-user flow at `views/default.py:98-102,119-131`. - **Every template that renders a removed field is fixed in the same change.** Jinja2's default `Undefined` raises on attribute access, so `user_edit.jinja2:27-28,36-37` will raise `UndefinedError` the moment the password fields disappear. - **`last_logged` gets an explicit writer and a NULL-safe renderer.** `User.verify_password` (`models/user.py:29`) is currently the **only** code in the app that writes it, so deleting that method deletes the only writer - and `templates/users.jinja2:27` calls `entry.last_logged.strftime(...)` with no NULL guard, which was verified to raise `UndefinedError`. The callback must set `last_logged`, and `/users` must survive a NULL. - **The pre-existing `delete(...)` `NameError` at `views/default.py:142` is fixed in this change.** It sits inside `user_edit`, which this migration edits regardless (lines 93, 98-102, 116-117, 119-131, 127, 137). `UserService.delete` already exists at `services/user.py:18`. The trap: if the `'admin'` literal in `user_edit.jinja2:45` is swapped for the `effective_principals` check, the delete button **still renders for admins and the `NameError` survives the migration**. - **Hand-written schema change against the committed `ctp_blogr.sqlite`**, with an explicit warning that this repo has no `alembic.ini` and no `migrations/` directory, so the schema cannot be regenerated. A SQLite **table rebuild** (not a plain `ALTER TABLE`) is required because `users.password` is `nullable=False`. `entries.creator` / `entries.editor` are `Unicode(50)` and are written straight from `request.authenticated_userid` with no length guard, so they are widened in the same rebuild. - **A branch decision and a documented restore path for `ctp_blogr.sqlite` are prerequisites, not afterthoughts.** There is a single commit on `master` with `main` and `origin/*` at the same commit, and this migration performs a destructive rebuild on a tracked file. - **`scope=openid email` only.** `profile` is not requested (no display name or avatar is used). **`hd` is deliberately omitted**: it means "a Workspace account on this domain", and `ctphuoc@gmail.com` is a personal Gmail account, so setting `hd=caotek.fr` would lock the second user out. The database allowlist is the control that actually works. - **A brand-new WebTest harness.** The existing `BaseTest` builds a `testing.DummyRequest`, not a WSGI app, so WebTest cannot drive it - and `BaseTest.init_database()` is **defined but never called** by either existing test, so no table is ever created. "WebTest on the existing fixture" is impossible; the plan therefore builds its own WSGI app on `sqlite:///:memory:` with the Google collaborator stubbed and `pyramid_retry` disabled, covering the happy path and every failure path. - **All user-visible strings, comments and flash messages stay in French**, matching the app. ### Must NOT have (guardrails, anti-slop, scope boundaries) - **No implicit sign-up.** A Google identity that is not already in `users` is refused; it is never auto-created. This closes the self-registration hole that a naive OAuth migration opens. - **No `profile` scope, no avatar, no display name, no refresh token, no `access_type=offline`, no `hd` parameter, and no call to any Google API.** Sign-in only. - **No Google-side logout or token revocation.** Logout is local session invalidation only. - **No database-backed groupfinder and no `tm.annotate_user` change.** The login allowlist is a DB read performed once in the callback; the group mapping is configuration. Silencing `annotate_user` to dodge the tween problem would break commit/abort semantics and is forbidden. - **No `pyramid_csrf` package and no retrofitting CSRF onto the existing write views.** Pyramid 2.1's cookie session already provides `new_csrf_token()` / `get_csrf_token()` under `_csrft_`, so no dependency is needed - but the token is a single long-lived per-session value with no automatic comparison helper, and wiring it into every pre-existing POST view is an unrelated refactor. `state` is the control for the callback itself (a single-use GET bound to the pre-authentication session). The pre-existing absence of CSRF, and `logout` being a state-mutating GET, are recorded as accepted risks, not silently fixed and not silently ignored. - **No server-side session backend.** No `pyramid_beaker`. Measured, not assumed: identity plus `state` + `code_verifier` + `nonce` serializes to **324 bytes**; with ten French flash messages, 662. Pyramid raises a hard `ValueError` at **4064 bytes**, leaving ~3.7 KB of headroom. The only growth vector is unbounded flash accumulation, which is bounded by capping flash length, not by adding a backend. - **No migration tooling.** Do not introduce Alembic, do not create a `migrations/` directory, and do not pretend `Base.metadata.create_all()` covers the production schema. - **No use of coverage as an acceptance gate.** `.coveragerc` still says `source = pyramid_blogr` (the old package name) so coverage collects nothing; the number would be fiction. Fix the name only if asked. - **No unrelated refactors beyond the lines this migration must already touch.** Explicitly **out of scope**: the hardcoded post id `95` in `home` and `home.jinja2:18`, the legacy `Query.get()` call in `services/blog_record.py`, the non-raw string at `routes.py:5`, the legacy `declarative_base` in `models/meta.py`, the missing `initialize_db.py` behind the declared `initialize_ctp_blogr_db` console script, and the dead `HistoForm` / `AllocationForm` / `ActifForm` / `Actif2Form` forms matching orphaned tables. The pre-existing draft-leak in `by_id` (no status filter) and the `LIKE` search leak are **recorded, not fixed**: they are unchanged by this migration, and widening the user population is what makes them worth a separate decision. - **No invented linting, formatting, type-checking, CI or task runner.** None exists and this migration does not create any. - **No reduction to an MVP or phase 1.** This is the complete migration, one plan. ## Verification strategy > Zero human intervention for everything the agent can execute. The single exception is F3's real > Google sign-in, which needs credentials only the user can issue - see the prerequisite below. > Todos 1-14 all run fully offline against a stubbed Google collaborator. > > **User prerequisite, required before F3 only (not before Wave 1):** provision a Google Cloud > OAuth 2.0 client, add the Web application redirect URI > `http://localhost:9280/login/google/callback`, and set `ENV_GOOGLE_CLIENT_ID` / > `ENV_GOOGLE_CLIENT_SECRET`. Todos 1-14 do not need these; F3 cannot pass without them. - Test decision: **tests-after**, WebTest + pytest, on `sqlite:///:memory:` via a **newly built** WSGI test app (the existing `BaseTest` cannot be reused - see Scope). The Google collaborator and the JWKS fetch are injected behind the named seam from todo 7 so no test performs network I/O. `pyramid_retry` is disabled in tests, because single-shot side effects such as consuming `state` are otherwise non-deterministic. - **Never assert a bare 200 in this app.** Unauthorized requests return **200 with the login page**, not 403, because a `forbidden_view_config` swallows `HTTPForbidden`. Every protected-route assertion checks status *and* body content. - Per-todo happy AND failure QA, each writing evidence to `/task--google-oauth-migration.`. - Structural self-check before handoff: every implementation row is column-zero `- [ ] N. ` and every final-verifier row is column-zero `- [ ] F<number>. <title>`, both inside their intended section, with no prose bullet standing in for a task. ### Acceptance criteria (transcribed from the gap analysis, grouped) **Environment / boot** 1. `python -c "from pyramid.paster import get_app; get_app('development.ini')"` exits 0, and `python -c "import authlib, joserfc; print(authlib.__version__)"` succeeds. 2. `python -m pytest -q` reports the exact new count, `0 failed`, and **no** `no such table` error (which proves the new harness actually creates the schema). **Session policy** 3. The post-login cookie, read on the **next** request, contains `auth.userid` and **no** key matching `state|code_verifier|nonce`. 4. A session older than the configured `timeout` is rejected, asserted with a monkeypatched clock rather than a sleep. 5. The factory is constructed with `secure=True` under production settings and `httponly=True` in both; the configured `timeout` is asserted to exceed the previous 20-minute default and its value is written down. **Callback** 6. Unknown email -> assert the **actual** status (403), that the body contains the French rejection message, and that it contains **no** `<input type="password">`. 7. `state` mismatch, missing `state`, and replayed `state` each fail closed, call the token endpoint **zero** times, and leave no session identity. 8. `nonce` mismatch is rejected; assert the JWT `nonce` claim is compared against the stored value (the stub returns a mismatching claim). 9. `?error=access_denied` -> French flash, no DB write, no session identity. 10. Retry safety: with `retry.attempts=3`, a transient error during the callback does **not** produce a second exchange (assert collaborator call count == 1). **Access control** 11. Authenticated as `ctphuoc@gmail.com`: `GET /user_edit/phuoc@caotek.fr` is refused, `GET /user_edit/0` is refused, `GET /users` does **not** contain `users_list`. 12. Same user: `POST /blog_edit/<id>` with `form.submitted` does **not** modify the post, and with `form.deleted` the row is still present. 13. `users.name` is restricted to the two allowlisted emails, and `groupfinder('ghost@nowhere.test', request)` returns `None`. **Templates / removals** 14. `GET /users` returns 200 with a row whose `last_logged` is NULL in the database (no `strftime` `UndefinedError`). 15. `GET /user_edit/<email>` returns 200 and the HTML contains **no** `name="password"`. 16. `grep -c "route_url('user_edit', name='0')" ctp_blogr/templates/users.jinja2` == 0 and `GET /user_edit/0` is unreachable. 17. `grep -rn passlib ctp_blogr/` returns nothing **and** the app still boots (the import-order trap). 18. `grep -rn "'admin'" ctp_blogr/` returns no authorization comparison. **Schema** 19. After the rebuild, `PRAGMA table_info(users)` shows **no** `password` column and `name` still `NOT NULL` + `UNIQUE`; `SELECT COUNT(*) FROM users` == 2 with the correct emails; `PRAGMA foreign_key_check` is clean; the `alembic_version` row is still present and untouched. 20. `PRAGMA table_info(entries)` shows `creator`/`editor` widened. **Regression** 21. `python -m pytest ctp_blogr/tests.py` still passes, or the two legacy duplicate tests are deliberately deleted as part of the harness replacement. 22. An explicit smoke pass over all 15 routes asserting **content**, never a bare 200. ## Execution strategy ### Parallel execution waves > Waves are deliberately smaller than the 5-8 target. The gap analysis proved that the session > factory, the auth policy and the group mapping are **one coupled change** (a 20-minute default > timeout plus a database-backed callback inside the transaction tween), and that the destructive > table rebuild must be isolated. Splitting those further would produce intermediate states that > cannot boot. - **Wave 1 - prerequisites and foundation (no interdependencies):** dependency manifest and `authlib` install; branch decision plus database backup and restore path; session factory hardening; the `settings` surface for the client id/secret and `auth.admin_emails`. - **Wave 2 - auth core (coupled, land together):** the `SessionAuthenticationPolicy` swap with the settings-based groupfinder; the destructive schema rebuild on the agreed branch; the named Google collaborator with JWKS caching. - **Wave 3 - the flow (depends on Wave 2):** the authorize redirect view; the callback view with state consumption, nonce comparison and a single non-retryable exchange; the allowlist gate with the explicit 403, `remember()`, the `last_logged` write and OAuth-key cleanup. - **Wave 4 - authorization gaps and surface removal (must land in the same change set as Wave 3):** the `/user_edit` permission and ownership fix together with the `delete(...)` `NameError`; the four literal `'admin'` sites; removal of the password surface and the template fixes it forces. - **Wave 5 - tests (depends on all):** the new WSGI harness and the full suite. - **Wave 6 - final verification wave** (F1-F4), all four in parallel, all must APPROVE. ### Dependency matrix | Todo | Depends on | Blocks | Can parallelize with | | --- | --- | --- | --- | | 1. Dependency manifest + `authlib` install | - | 7, 13 | 2, 3, 4 | | 2. Branch decision + DB backup/restore path | - | 6 | 1, 3, 4 | | 3. Session factory hardening (timeout, secret, httponly, secure) | - | 5, 8, 9 | 1, 2, 4 | | 4. `settings` surface: client id/secret + `auth.admin_emails` | - | 5, 7, 8 | 1, 2, 3 | | 5. `SessionAuthenticationPolicy` + settings-based groupfinder | 3, 4 | 10, 11, 12 | 6, 7 | | 6. Schema rebuild: drop `password`, add `google_sub`, widen creator/editor | 2 | 9, 10, 14 | 5, 7 | | 7. Google collaborator seam + JWKS cache | 1, 4 | 8 | 5, 6 | | 8. Authorize redirect + callback views | 3, 7 | 9, 13 | - | | 9. Allowlist gate + explicit 403 + `last_logged` + key cleanup | 5, 6, 8 | 13 | - | | 10. `/user_edit` authorization + `delete(...)` `NameError` | 5, 6, 9 | 13 | - | | 11. Replace the 4 literal `'admin'` sites | 5, 9 | 13 | 10 | | 12. Remove the password surface + template fixes | 6, 10, 11 | 13 | - | | 13. New WSGI test harness + full suite | 1-12 | F1-F4 | - | | 14. `AGENTS.md` accuracy update | 13 | F1-F4 | - | ## Todos > Implementation + Test = ONE todo. Never separate. <!-- APPEND TASK BATCHES BELOW THIS LINE WITH edit/apply_patch - never rewrite the headers above. --> - [ ] 1. Declare and install `authlib` without corrupting the shared venv What to do / Must NOT do: Add `authlib` to `install_requires` in `setup.py` (which pulls in `joserfc`). Install it into the parent venv at `/Users/phuoc/pyramid2`. MUST NOT casually `pip install` into that venv - `AGENTS.md` records that it is shared and still carries a stale editable install of a `wiki_test` project whose source is gone. MUST NOT add any other new dependency, and MUST NOT introduce `pyramid_csrf` or `pyramid_beaker`. Parallelization: Wave 1 | Blocked by: - | Blocks: 7, 13 References (executor has NO interview context - be exhaustive): `setup.py` (install_requires, currently declares the dead `initialize_ctp_blogr_db` console script whose target `ctp_blogr/scripts/initialize_db.py` does not exist); `AGENTS.md` "Environment" section; venv root is the **parent** directory, `python` is already on PATH. Acceptance criteria (agent-executable): `python -c "import authlib, joserfc; print(authlib.__version__)"` exits 0 and prints 1.8.0; `python -c "from pyramid.paster import get_app; get_app('development.ini')"` exits 0; `git diff setup.py` shows exactly one added dependency line. QA scenarios (name the exact tool + invocation): happy - `python -c "import authlib, joserfc"` succeeds after install. failure - `pip install authlib==0.0.0` is NOT run; instead assert `grep -c "authlib" setup.py` == 1 and that `pyramid_csrf`/`pyramid_beaker` are absent. Evidence: `<attemptDir>/task-1-google-oauth-migration.<ext>` Commit: N - propose `feat(deps): add authlib for Google OAuth`, commit only on explicit request. - [ ] 2. Decide the branch and establish a restore path for the tracked database What to do / Must NOT do: Surface the branch decision to the user and get an explicit answer before any schema work. `ctp_blogr.sqlite` is **tracked in git** (the `*.sqlite` line in `.gitignore` does nothing for an already-tracked file), there is a single commit on `master`, and `main` and `origin/*` point at it. Produce a dated backup of the database file outside the repo and record the exact restore command. MUST NOT create a branch, commit, or push without the user's answer. MUST NOT modify the schema in this todo. Parallelization: Wave 1 | Blocked by: - | Blocks: 6 References (executor has NO interview context - be exhaustive): `AGENTS.md` "Database" and "Conventions" sections; `development.ini` and `production.ini` both point at the file via `%(here)s`; the file contains `alembic_version` rev `b8f8216d72c7`, ~99 posts and 8 tags. Acceptance criteria (agent-executable): a backup file exists outside the working tree and `sqlite3 <backup> "PRAGMA integrity_check"` returns `ok`; the restore command is written into the plan's commit strategy; `git status --porcelain ctp_blogr.sqlite` is still clean at the end of this todo. QA scenarios: happy - backup created and verified with `PRAGMA integrity_check`. failure - confirm `git checkout -- ctp_blogr.sqlite` would restore the pre-migration state, and record that the working tree is untouched so far. Evidence: `<attemptDir>/task-2-google-oauth-migration.<ext>` Commit: N - documentation only, no commit. - [ ] 3. Harden and reconfigure the session factory What to do / Must NOT do: Replace the bare `SignedCookieSessionFactory('hZug2zPt7hT2MZ')` with an explicit construction: a secret of at least 64 characters for `hashalg='sha512'` (currently 16), an explicit `timeout` long enough to survive a Google consent screen plus sign-in plus 2FA (the default is 1200s / 20 min against the outgoing policy's 36000s / 10 h), `httponly=True` in both environments, and `secure=True` driven by settings so production gets it. MUST NOT add `pyramid_beaker`. MUST NOT call `request.session.renew()` - **that method does not exist in Pyramid 2.1**, and neither do `SessionRenew` / `SessionInvalidate`. MUST NOT leave the factory defaults in place, because the cookie becomes the identity. Parallelization: Wave 1 | Blocked by: - | Blocks: 5, 8, 9 References (executor has NO interview context - be exhaustive): `ctp_blogr/__init__.py:13` (the factory) and `:15` (the hardcoded auth-ticket secret being removed); the installed factory source confirms `_timeout: 1200`, `_cookie_secure: False`, `_cookie_httponly: False`, `_cookie_samesite: 'Lax'`, `_cookie_on_exception: True`; `production.ini:29` declares `url_scheme = https` while the cookie still ships no `Secure` flag; `templates/layout.jinja2:14-15,106-107` load third-party CDN scripts, so `httponly=False` is a real XSS-to-identity-theft path once the cookie carries the identity. `SameSite=Lax` is already correct for a top-level GET callback - do not change it to `None`. Acceptance criteria (agent-executable): `grep -n "SignedCookieSessionFactory" ctp_blogr/__init__.py` shows named kwargs for `timeout`, `httponly`, `secure` and the secret; the secret is >= 64 chars (assert with a Python one-liner); `python -c "from pyramid.paster import get_app; get_app('development.ini')"` exits 0; a `Set-Cookie` from a test request carries `HttpOnly`, and carries `Secure` when production settings are loaded. QA scenarios: happy - boot check passes and the header assertions hold. failure - assert a monkeypatched clock past the configured `timeout` causes the identity to be rejected (criterion 4), with no `sleep` in the test. Evidence: `<attemptDir>/task-3-google-oauth-migration.<ext>` Commit: N - propose `fix(auth): harden and configure the signed session cookie`. - [ ] 4. Add the `settings` surface for the Google client and the admin email list What to do / Must NOT do: Read `google.client_id` and `google.client_secret` from settings via PasteDeploy environment interpolation (`%(ENV_GOOGLE_CLIENT_ID)s`, `%(ENV_GOOGLE_CLIENT_SECRET)s`) in both `development.ini` and `production.ini`, and add `auth.admin_emails` as a parsed list (`phuoc@caotek.fr`). Fail fast with a clear French message if the credentials are missing. MUST NOT commit any real credential, and MUST NOT invent a settings key that nothing reads. Parallelization: Wave 1 | Blocked by: - | Blocks: 5, 7, 8 References (executor has NO interview context - be exhaustive): `ctp_blogr/__init__.py` `main()` (reads `settings`); `development.ini` and `production.ini`; `ctp_blogr/security.py` (the 3-line ACL whose `group:administrators` principal this list feeds). Acceptance criteria (agent-executable): `grep -n "auth.admin_emails" ctp_blogr/*.ini` shows both files; the groupfinder receives `['phuoc@caotek.fr']` from settings; starting the app with the env vars unset raises a clear configuration error naming the missing variable, and `git grep -n "client_secret" ` shows only the interpolation, never a literal value. QA scenarios: happy - app boots with env vars set. failure - app refuses to boot with a readable message when they are absent. Evidence: `<attemptDir>/task-4-google-oauth-migration.<ext>` Commit: N - propose `feat(auth): add Google OAuth and admin email settings`. - [ ] 5. Swap `AuthTkt` for `SessionAuthenticationPolicy` with a settings-based groupfinder What to do / Must NOT do: Replace `AuthTktAuthenticationPolicy` (and its hardcoded secret) with Pyramid's `SessionAuthenticationPolicy` + `SessionAuthenticationHelper`, keeping `root_factory='ctp_blogr.security.RootFactory'` and `ACLAuthorizationPolicy` so `security.py` is untouched. Rewrite the `groupfinder` callback so it maps email -> group **purely from `auth.admin_emails` in settings**. MUST NOT query the database from the callback: `pyramid_tm` defaults `tm.annotate_user` to `True` and calls `request.authenticated_userid` inside `manager.begin()`, so a DB-backed groupfinder runs a SELECT on every request inside the transaction, and any DB error 500s every route including `/login` and the callback itself. MUST NOT "fix" this by setting `tm.annotate_user = false`. Unknown email must return `None`, not raise. Parallelization: Wave 2 | Blocked by: 3, 4 | Blocks: 10, 11, 12 References (executor has NO interview context - be exhaustive): `ctp_blogr/__init__.py:15` (the policy block and secret); `ctp_blogr/services/user.py:26` (the current `userid == 'admin'` literal to remove); `ctp_blogr/security.py`; installed `pyramid_tm/__init__.py` `annotate_user = asbool(settings.get('tm.annotate_user', True))` and the `request.authenticated_userid` access inside `manager.begin()`; `models/__init__.py` sets `settings['tm.manager_hook'] = 'pyramid_tm.explicit_manager'`. Acceptance criteria (agent-executable): `groupfinder('phuoc@caotek.fr', request)` returns `['group:administrators']`; `groupfinder('ctphuoc@gmail.com', request)` returns `[]`; `groupfinder('ghost@nowhere.test', request)` returns `None`; no SQL is emitted during a groupfinder call (assert with an engine event listener); `grep -rn "'admin'" ctp_blogr/` returns nothing; the ACL file is byte-identical to its pre-change content. QA scenarios: happy - both known identities resolve to the expected principals and `/users` is reachable only for the admin. failure - `groupfinder` on an unknown email returns `None` and creates no row; a request with the database deliberately broken still serves `/login` (this is the B3 regression test). Evidence: `<attemptDir>/task-5-google-oauth-migration.<ext>` Commit: N - propose `refactor(auth): swap AuthTkt for SessionAuthenticationPolicy`. - [ ] 6. Rebuild the `users` table and widen `entries.creator` / `entries.editor` What to do / Must NOT do: Against the backed-up `ctp_blogr.sqlite`, rebuild `users` to drop the `password` column (it is `nullable=False`, so a plain `ALTER TABLE ... DROP COLUMN` is not the safe route), add a nullable unique `google_sub`, keep `id` / `name` (`name` stores the allowlisted email and stays `NOT NULL` + `UNIQUE`) and `last_logged`. Set row 1 `name` to `phuoc@caotek.fr` and row 2 to `ctphuoc@gmail.com`. Widen `entries.creator` and `entries.editor` from `Unicode(50)` to `Unicode(255)` in the same rebuild. Back up first (todo 2). MUST NOT introduce Alembic or a `migrations/` directory - none exists in this repo. MUST NOT touch the `alembic_version` row (rev `b8f8216d72c7`), even though no Alembic is wired up. MUST NOT drop or alter the `entries` rows themselves. Report clearly that migrations are missing. Parallelization: Wave 2 | Blocked by: 2 | Blocks: 9, 10, 14 References (executor has NO interview context - be exhaustive): `ctp_blogr/models/user.py` (columns, `password nullable=False`); `ctp_blogr/models/blog_record.py` (`creator`/`editor` `Unicode(50)`); `ctp_blogr/models/__init__.py:8-9` (`Tags` is defined in `blog_record` but not imported here - import it explicitly if mapper config is touched); `ctp_blogr/views/blog.py:64,75` (the unguarded writes); `AGENTS.md` "Database" section (the DB file is the schema source of truth; `create_all()` is test-only). Acceptance criteria (agent-executable): `PRAGMA table_info(users)` shows no `password` column, `name` still `NOT NULL` + `UNIQUE`, and a `google_sub` column; `SELECT COUNT(*) FROM users` == 2 with exactly `phuoc@caotek.fr` and `ctphuoc@gmail.com`; `PRAGMA table_info(entries)` shows `creator`/`editor` widened; `PRAGMA foreign_key_check` is clean; `SELECT * FROM alembic_version` still returns `b8f8216d72c7`; `SELECT COUNT(*) FROM entries` is unchanged from the pre-migration count; `python -m pytest -q` shows no `no such table`. QA scenarios: happy - all the assertions above pass against the real file. failure - run every assertion against the **backup** first to confirm the script is not silently a no-op, and confirm the restore path from todo 2 actually restores a working app. Evidence: `<attemptDir>/task-6-google-oauth-migration.<ext>` Commit: N - propose `feat(db): migrate users to Google identity and widen entry authors`. The database diff is a real tracked change and must be inspected before any commit. - [ ] 7. Build the named Google OAuth collaborator with JWKS caching What to do / Must NOT do: Add a registry-registered service (e.g. `ctp_blogr/services/google_oauth.py`) wrapping `OAuth2Session` from `authlib.integrations.requests_client`, configured from the discovery document so `authorize_url` / `access_token_url` / `jwks_uri` are never hand-written. Expose exactly two operations: `create_authorization_url(...)` and `fetch_claims(code, code_verifier, ...)`. The service MUST fetch **and cache** the discovery document and the JWKS itself with a bounded TTL, because the bare client does neither: `CodeIDToken` plus key decoding needs the keys pulled from `jwks_uri` manually, and refetching on every sign-in is a latency and rate-limit problem. Use **`joserfc`** (Authlib's own forward dependency) to decode and verify the `id_token`, **not** `authlib.jose`, which is soft-deprecated in 1.8.0 and warns. Set `hd` nowhere. MUST NOT use `authorize_redirect` / `save_authorize_data` / `set_state_data` / `clear_state_data` - those are framework integrations that do not exist for Pyramid. Parallelization: Wave 2 | Blocked by: 1, 4 | Blocks: 8 References (executor has NO interview context - be exhaustive): discovery URL `https://accounts.google.com/.well-known/openid-configuration`; Authlib entry point `authlib.integrations.requests_client.OAuth2Session`; `create_authorization_url` returns the tuple `(uri, state)` and does **not** return `code_verifier`, which the app generates with `generate_token(48)` and passes in itself; Google's OIDC reference (https://developers.google.com/identity/openid-connect/reference) requires `nonce`; `development.ini` / `production.ini` settings from todo 4. Acceptance criteria (agent-executable): the service is reachable from `request.registry.google_oauth`; `create_authorization_url` returns a `state` and an absolute `accounts.google.com` URI containing `code_challenge_method=S256`, a `code_challenge`, an `openid` scope and **no** `hd` parameter; two consecutive `fetch_claims` calls hit the JWKS endpoint once (assert with a counting stub); the cache honours its TTL under a monkeypatched clock. QA scenarios: happy - authorization URL is well-formed and JWKS is fetched once. failure - a JWKS fetch failure surfaces a French error and never reaches the token endpoint; a cached-key path still validates a token after the TTL expires. Evidence: `<attemptDir>/task-7-google-oauth-migration.<ext>` Commit: N - propose `feat(auth): add Google OAuth client with state, PKCE and nonce`. - [ ] 8. Add the authorize redirect and the callback views What to do / Must NOT do: Add a `/login/google` route that generates `state`, `nonce` and `code_verifier`, stores all three in the session, and redirects. Add the `/login/google/callback` GET which: rejects `?error=access_denied` with a French flash and no DB write; consumes (deletes) `state` **before** attempting the exchange so a replay cannot reach the token endpoint; exchanges the code exactly once with the stored `code_verifier` and `redirect_uri`; compares the `id_token` `nonce` claim against the stored value; deletes `state` / `code_verifier` / `nonce`; and then defers to todo 9 for the allowlist gate and `remember()`. MUST NOT call `request.session.renew()` (it does not exist). MUST NOT let `pyramid_retry` replay the exchange: `pyramid_tm` marks exceptions retryable and a SQLite lock would re-run the callback into `invalid_grant`. Verify with a call-count assertion under `retry.attempts=3`. Parallelization: Wave 3 | Blocked by: 3, 7 | Blocks: 9, 13 References (executor has NO interview context - be exhaustive): `ctp_blogr/routes.py` (add both routes; note `:5` is a non-raw string emitting `SyntaxWarning` on 3.12+ - prefix with `r` if you edit that file, the new routes are purely additive); `ctp_blogr/views/default.py:37-74` (the existing `login` / `logout` / `forget` pattern and its `permission` / `renderer` conventions); `development.ini` (`retry.attempts = 3`); installed `pyramid_tm` retryable-error tagging. Acceptance criteria (agent-executable): `state` mismatch, missing `state`, and replayed `state` each fail closed, call the collaborator **zero** times, and leave no session identity; `nonce` mismatch is rejected with a stubbed mismatching claim; `?error=access_denied` produces a French flash, no DB write and no session identity; with `retry.attempts=3` and an injected transient error the collaborator is called exactly once; after a successful callback no session key matches `state|code_verifier|nonce`. QA scenarios: happy - full redirect-URI round trip with a stubbed token endpoint yields verified claims. failure - the six rejection cases above, each asserting zero token-endpoint calls. Evidence: `<attemptDir>/task-8-google-oauth-migration.<ext>` Commit: N - propose `feat(auth): add authorize and callback views`. - [ ] 9. Gate sign-in on the database allowlist, then remember the identity What to do / Must NOT do: In the callback, after claims are verified, match the identity against the `users` table: `google_sub` first, then the allowlisted email. A match updates `last_logged` (the **only** writer, since `verify_password` is being deleted) and stores `google_sub` on first sign-in, then calls `remember(email)`. **No match is refused** and no row is ever created. The refusal MUST set `request.response.status_int = 403` explicitly and render a French rejection template **without raising `HTTPForbidden`**, because `forbidden_view_config` on login (`views/default.py:38`) catches that exception and renders `login.jinja2` with status **200** - a rejected user would otherwise be shown the dead password form. Replace that `forbidden_view_config` with a real 403 view rather than inheriting it. MUST NOT auto-provision. MUST NOT query the DB for the group mapping (todo 5). Parallelization: Wave 3 | Blocked by: 5, 6, 8 | Blocks: 13 References (executor has NO interview context - be exhaustive): `ctp_blogr/views/default.py:38` (the `forbidden_view_config` that swallows `HTTPForbidden`), `:53-58` (existing `remember()` / `forget()` call style), `:68-74` (logout); `ctp_blogr/models/user.py:29` (the `last_logged` write being removed with `verify_password`); `templates/users.jinja2:27` (`last_logged.strftime` with no NULL guard); Google's production-readiness note that the test-user allowlist is bypassed for basic scopes, which is why this DB gate is mandatory rather than defence-in-depth. Acceptance criteria (agent-executable): unknown email -> status 403, body contains the French rejection message, body contains no `<input type="password">`, and `SELECT COUNT(*) FROM users` is unchanged; `phuoc@caotek.fr` yields `auth.userid` equal to that email plus the `manage` principal and a non-NULL `last_logged`; `ctphuoc@gmail.com` yields a session **without** `manage`; a second sign-in matches on `google_sub`. QA scenarios: happy - both known identities sign in with the right principals. failure - unknown email refused at 403 with no row created; a tampered `sub`/email pair refused. Evidence: `<attemptDir>/task-9-google-oauth-migration.<ext>` Commit: N - propose `feat(auth): gate sign-in on a database allowlist`. - [ ] 10. Close the `/user_edit` authorization hole and fix the `delete(...)` `NameError` What to do / Must NOT do: `views/default.py:88` declares `permission='view'`, **not** `manage`, and the only identity check in the function is `== 'admin'` for the return URL. Any authenticated user can therefore open `/user_edit/<any-email>` and rewrite that row, or `/user_edit/0` to create one. Change the permission to `manage` and remove the create-user branch (`:98-102,119-131`) so `name='0'` is unreachable; if an admin-only allowlist editor is kept, it must require `manage` and must not accept a password. In the same change, fix `:142` `delete(request, user.id)` -> `UserService.delete(request, user.id)`; that method already exists at `services/user.py:18`. Record that it is a `filter().delete(synchronize_session=False)` with **no FK cascade** from `entries.creator` / `entries.editor`, so deleting a user strands PII in those columns. MUST NOT leave the `NameError` live: `user_edit.jinja2:45` gates the delete button on `request.authenticated_userid == 'admin'`, and swapping that literal for the `effective_principals` check keeps the button rendering for admins. Parallelization: Wave 4 | Blocked by: 5, 6, 9 | Blocks: 13 References (executor has NO interview context - be exhaustive): `ctp_blogr/views/default.py:88` (permission), `:93` (`'admin'` literal), `:98-102` and `:119-131` (create-user branch), `:116-117` (`len(form.password.data) < 6`), `:127` and `:137` (`set_password`), `:142` (the `NameError`); `ctp_blogr/services/user.py:18` (`delete`); `ctp_blogr/templates/user_edit.jinja2:45` (delete-button gate); `ctp_blogr/templates/users.jinja2` (the `route_url('user_edit', name='0')` create link). Acceptance criteria (agent-executable): as `ctphuoc@gmail.com`, `GET /user_edit/phuoc@caotek.fr` and `GET /user_edit/0` are both refused and the response body does not contain the edit form; `grep -c "route_url('user_edit', name='0')" ctp_blogr/templates/users.jinja2` == 0; as admin, the delete button renders **and** submitting it deletes the row without raising; the other user row is untouched. QA scenarios: happy - admin edits and deletes correctly. failure - non-admin is refused on both URLs (content assertion, never a bare 200, because forbidden responses render the login page at 200 in this app). Evidence: `<attemptDir>/task-10-google-oauth-migration.<ext>` Commit: N - propose `fix(auth): require manage on user_edit and fix user deletion`. - [ ] 11. Replace the four literal `'admin'` comparisons with `effective_principals` What to do / Must NOT do: Replace every authorization comparison against the literal `'admin'` with `'group:administrators' in request.effective_principals`: `services/user.py:26`, `views/default.py:93`, `templates/layout.jinja2:43`, `templates/user_edit.jinja2:45`. Templates cannot call a groupfinder, which is why `effective_principals` is the correct template-side expression. Audit **all 15** `request.authenticated_userid` sites (7 Python, 8 templates) so none is left comparing an identity to a name. MUST NOT leave any `== 'admin'` behind. Parallelization: Wave 4 | Blocked by: 5, 9 | Blocks: 13 References (executor has NO interview context - be exhaustive): `ctp_blogr/services/user.py:26`; `ctp_blogr/views/default.py:93`; `ctp_blogr/templates/layout.jinja2:43` and `:46`; `ctp_blogr/templates/user_edit.jinja2:45`; `ctp_blogr/services/blog_record.py` x3, `ctp_blogr/views/blog.py:64,75`, x2, `ctp_blogr/views/default.py` x2, `templates/layout.jinja2` x4, `templates/blog.jinja2` x2, `templates/home.jinja2` x1, `templates/user_edit.jinja2` x1. Acceptance criteria (agent-executable): `grep -rn "'admin'" ctp_blogr/` returns no authorization comparison; as `phuoc@caotek.fr` the admin navigation item renders and `/users` is reachable; as `ctphuoc@gmail.com` it does not render and `/users` does not contain `users_list`. QA scenarios: happy - both identities see exactly the navigation they should. failure - the non-admin sees no admin-only control in any of the 15 audited sites. Evidence: `<attemptDir>/task-11-google-oauth-migration.<ext>` Commit: N - propose `refactor(auth): replace literal admin checks with effective_principals`. - [ ] 12. Remove the password surface and fix every template it breaks What to do / Must NOT do: Remove the `password` / `confirm` WTForms fields and the `len(form.password.data) < 6` check, `User.verify_password` / `set_password`, the top-level `import passlib` at `models/user.py:11`, and the `passlib` dependency. Fix every template that renders a removed field, because Jinja2's default `Undefined` raises on attribute access: `user_edit.jinja2:27-28,36-37`. Make `users.jinja2:27` NULL-safe for `last_logged`. Remove the non-admin self-service password path and the admin "Nouvel utilisateur" button. MUST NOT remove `import passlib` last - leaving it breaks the entire app at import time, since the app will not even boot. MUST NOT touch the dead `HistoForm` / `AllocationForm` / `ActifForm` / `Actif2Form` forms or their orphaned `histo` / `allocation` / `actifs` tables. Parallelization: Wave 4 | Blocked by: 6, 10, 11 | Blocks: 13 References (executor has NO interview context - be exhaustive): `ctp_blogr/forms.py` (password / confirm fields; plus the dead forms to ignore); `ctp_blogr/models/user.py:11` (import) and `:29` (`last_logged` writer); `ctp_blogr/views/default.py:116-117,127,137,98-102,119-131`; `ctp_blogr/templates/user_edit.jinja2:27-28,36-37`; `ctp_blogr/templates/users.jinja2:27`; `ctp_blogr/templates/login.jinja2` (replaced by a single "Se connecter avec Google" control); `setup.py` (the `passlib` requirement). Acceptance criteria (agent-executable): `grep -rn passlib ctp_blogr/` returns nothing **and** `python -c "from pyramid.paster import get_app; get_app('development.ini')"` still exits 0 (the import-order trap); `GET /users` returns 200 for a row whose `last_logged` is NULL; `GET /user_edit/<email>` returns 200 with no `name="password"` in the HTML; `grep -rn "form.password" ctp_blogr/` returns nothing. QA scenarios: happy - `/users` and `/user_edit/<email>` render clean with no password markup. failure - the NULL-`last_logged` row renders 200; the app boots with `passlib` uninstalled from the source tree. Evidence: `<attemptDir>/task-12-google-oauth-migration.<ext>` Commit: N - propose `refactor(auth): remove password forms and password storage`. - [ ] 13. Build a new WSGI test harness and the full offline suite What to do / Must NOT do: Build a **new** WSGI test app - the existing `BaseTest` constructs a `testing.DummyRequest`, so WebTest cannot drive it, and `BaseTest.init_database()` is defined but **never called**, so no table is ever created. The harness must create a real router on `sqlite:///:memory:` with `Base.metadata.create_all()`, register the stubbed Google collaborator from todo 7, and disable `pyramid_retry`. Cover the happy path and every failure path in the Acceptance criteria above: unknown email, state mismatch / missing / replay, nonce mismatch, `error=access_denied`, retry-safety call count, the `/user_edit` and `/users` authorization refusals, `POST /blog_edit` not mutating for a non-admin, the NULL-`last_logged` render, and the two legacy duplicate tests. MUST NOT perform network I/O. MUST NOT assert a bare 200 as proof of access. Decide explicitly whether the two legacy duplicate tests are kept or deliberately deleted. Parallelization: Wave 5 | Blocked by: 1-12 | Blocks: F1-F4 References (executor has NO interview context - be exhaustive): `ctp_blogr/tests.py` (`BaseTest`, `DummyRequest`, the uncalled `init_database`, the two duplicate tests); `pyramid.testing.DummyRequest` for the lighter alternative; `ctp_blogr/models/__init__.py`; the "Testing" section of `AGENTS.md` (pytest.ini sets `testpaths = ctp_blogr` and `python_files = test*.py`, so a new file must be named `test*.py` inside the package). Acceptance criteria (agent-executable): `python -m pytest -q` exits 0 with `0 failed` and an exact reported count, no `no such table` errors, and no outbound socket (assert by blocking `socket.socket` in a fixture); criteria 1-22 from Verification all pass. QA scenarios: happy - the complete sign-in flow passes end to end offline. failure - each of the ten failure criteria fails closed, most importantly that a non-admin cannot reach `/user_edit/<other-email>` and that a replayed `state` never reaches the token endpoint. Evidence: `<attemptDir>/task-13-google-oauth-migration.<ext>` Commit: N - propose `test(auth): cover the Google OAuth flow without network access`. - [ ] 14. Update `AGENTS.md` so it still describes reality What to do / Must NOT do: Rewrite the "Architecture / wiring" section for the new policy and the settings-based admin list, and correct "Known-broken" for the items this migration resolves: the `delete(...)` `NameError`, the 200-not-403 forbidden behaviour and its `forbidden_view_config` cause, and the auth-ticket secret. Remove the now-false statement that admin is a hardcoded `userid == 'admin'`. MUST NOT invent linting, CI or a task runner, and MUST NOT claim any command was run that was not. Note that coverage is still broken and still must not be trusted. Parallelization: Wave 5 | Blocked by: 13 | Blocks: F1-F4 References (executor has NO interview context - be exhaustive): `AGENTS.md` "Architecture / wiring", "Known-broken", "Conventions" and "Testing" sections. Acceptance criteria (agent-executable): `grep -n "AuthTkt" AGENTS.md` returns nothing; `grep -n "userid == 'admin'" AGENTS.md` returns nothing; every command listed in `AGENTS.md` "Commands" still runs as written. QA scenarios: happy - the documented wiring matches the code. failure - a reviewer greps each `AGENTS.md` claim and confirms it against the source. Evidence: `<attemptDir>/task-14-google-oauth-migration.<ext>` Commit: N - propose `docs: update AGENTS.md for the Google OAuth migration`. ## Final verification wave > Runs in parallel after ALL todos. ALL must APPROVE. Surface results and wait for the user's explicit okay before declaring complete. - [ ] F1. Plan compliance audit - confirm every todo's acceptance criteria and all 22 criteria in Verification were actually executed, with evidence files present. - [ ] F2. Code quality review - re-read the diff for the failure modes the gap analysis surfaced: retry replay, stale session values, the `passlib` import order, `UndefinedError` in templates, and any remaining literal identity comparison. - [ ] F3. Real manual QA - boot `pserve development.ini`, complete a real Google sign-in end to end in a browser, and smoke all 15 routes asserting **content** rather than bare 200. **Requires the Google Cloud OAuth client and registered redirect URI from the Verification prerequisite; if the user has not provisioned them, report F3 as BLOCKED-ON-USER rather than APPROVE or REJECT.** - [ ] F4. Scope fidelity - confirm nothing outside Scope changed: no `pyramid_csrf`, no `pyramid_beaker`, no Alembic, no `hd`, no offline access, no auto sign-up, no new tooling, and no change to the out-of-scope items (post id 95, `Query.get()`, `routes.py:5`, `declarative_base`, the dead forms, the draft-leak filters). ## Commit strategy The pre-existing history is two commits on `master` - `411ca8d "Inital commit since swicthing to python 3.14"` followed by `ea830da "opencode /init done"`; `main` and `origin/*` point at the same tip and the branch choice is **unsettled**. Per todo 2, an explicit branch decision is a prerequisite and no branch is created, no push is made, and nothing is committed unless the user asks - `AGENTS.md` states "Commit only when asked". Suggested messages, matching the repo's terse existing style: - `feat(deps): add authlib for Google OAuth` - `fix(auth): harden and configure the signed session cookie` - `feat(auth): add Google OAuth and admin email settings` - `refactor(auth): swap AuthTkt for SessionAuthenticationPolicy` - `feat(db): migrate users to Google identity and widen entry authors` - `feat(auth): add Google OAuth client with state, PKCE and nonce` - `feat(auth): add authorize and callback views` - `feat(auth): gate sign-in on a database allowlist` - `fix(auth): require manage on user_edit and fix user deletion` - `refactor(auth): replace literal admin checks with effective_principals` - `refactor(auth): remove password forms and password storage` - `test(auth): cover the Google OAuth flow without network access` - `docs: update AGENTS.md for the Google OAuth migration` **Restore path for `ctp_blogr.sqlite`** (established in todo 2, required before todo 6): `<backup path from todo 2>` restores the pre-migration database, and `git checkout -- ctp_blogr.sqlite` restores the committed version. The database diff is a real tracked change and must be inspected before any commit. ## Success criteria - Signing in with `phuoc@caotek.fr` yields a session whose `authenticated_userid` is that email and which carries the `group:administrators` principal; the `/users` page is reachable. - Signing in with `ctphuoc@gmail.com` yields a session WITHOUT `manage`; `/users` does not expose the user list. - Signing in with any other Google account produces an explicit **403** with a French message and **no** password input on the page, creates no database row, and makes no group lookup. - The password column, passlib, the password form fields, and the local login form are gone from the codebase; no route, template or form field still references them, and the app still boots. - No source line compares an identity to the literal `'admin'`; the admin mapping is the `auth.admin_emails` setting. - No session key matching `state|code_verifier|nonce` survives a completed sign-in. - A replayed or mismatched `state`, and a mismatched `nonce`, each reach the token endpoint zero times and leave no session identity. - `python -m pytest` passes with the new tests, no test performs network I/O, and no test asserts a bare 200 as proof of access. - `pserve development.ini` serves `/`, and `/login` presents a single "Se connecter avec Google" control instead of a credential form. - `AGENTS.md` is updated: its "Architecture / wiring" and "Known-broken" sections still describe reality after the migration (it currently documents the AuthTkt + groupfinder wiring, the 200-not-403 behaviour, and the `delete(...)` NameError that this plan fixes).