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

34 KiB

slug, intent, review_required, classification, status, pending-action, approach
slug intent review_required classification status pending-action approach
google-oauth-migration clear false architecture drafting write .omo/plans/google-oauth-migration.md <pending research + interview>

Draft: google-oauth-migration

Components (topology ledger)

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)

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:
    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.