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:142delete(...)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.
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 (.coveragercsource = 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:46request.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:13user_edit / views/default.py:79users 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:
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
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 INPUTRESOLVED 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
"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.
"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.
"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.