Status: ✅ Complete — shipped August 14, 2026 Effort Class: Medium. One migration, one route, one controller, one resolver, a policy bump. Surfaces: Signup (alum-facing) + shared model. No held-surface writes — see §2. Prerequisites: 28.1. Auto-linking assumes one row per email.
A “Continue with Google” button on /sign-up and /sign-in, both posting to one
endpoint, resolving through one four-way resolver, and never creating a record.
Coverage on day one is roughly two-thirds of returning alumni (134 of 200 active rows are
gmail.com) with nobody having linked anything first, because a verified email match
auto-links on first use. This is why the button ships before the Connected Accounts screen
rather than after it.
GET /auth/google_oauth2/callback on alumnichampions.com already resolves to
Cp::OmniauthCallbacksController. The portal’s route constraint matches the signup
domain as well as its own (routes.rb:9-13) and the
callback sits inside it (routes.rb:43).
Three ways out, two of them wrong:
| Approach | Verdict |
|---|---|
Narrow the Cp:: constraint to drop alumnichampions |
Rejected. Changes held-surface routing behavior. Needs approval this phase does not have, and the blast radius is a frozen app nobody is currently testing. |
Add a signup route ahead of the Cp:: one |
Rejected. Same path, resolved by declaration order. Works until someone reorders the file, and nothing would fail loudly when they do. |
| Register a second provider name | Chosen. /auth/signup_google/callback — no collision, no held-surface edit, no ordering dependency. |
# config/initializers/devise.rb — a SECOND entry alongside the existing one
config.omniauth :signup_google, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_CLIENT_SECRET"],
{ strategy_class: OmniAuth::Strategies::GoogleOauth2,
scope: "email,profile", prompt: "select_account", name: "signup_google" }
Same Google Cloud project, same credentials, one additional authorized redirect URI —
which alumnichampions.com needs registered regardless, since it has never had one.
Route goes at root level, host-constrained to the signup domain, outside the Cp::
block. Not inside a devise_scope — ChampionSignup is not a Devise model and the
callback controller inherits from Signup::BaseController, not
Devise::OmniauthCallbacksController.
One method, four outcomes, in order. Lives in Signup::GoogleIdentity (a service, not a
model method — it reaches across ChampionSignup, Alumni, and AlumniLookupService,
and none of those is a natural owner; same reasoning that put Signup::Identity in
app/services/signup/).
| # | Match on | Result | Event |
|---|---|---|---|
| 1 | google_uid |
Sign in | signin_completed, method: "google" |
| 2 | Verified email → find_active_by_email |
Sign in, store google_uid |
signin_completed + google_linked (auto: true) |
| 3 | Verified email → AlumniLookupService.find(email:) → BUID → active signup |
Sign in, store google_uid |
signin_completed + google_linked (auto: true) |
| 4 | Nothing | Do not create. Hand off to /sign-up with locked prefill (28.3) |
none — no record to attach one to |
email_verified is a hard gateCheck it before steps 2 and 3. Google can return an unverified address under some Workspace configurations, and an unverified address that auto-links to an existing record is account takeover.
Neither existing implementation does this. Cp::Champion.from_omniauth
(cp/champion.rb:1742) and
User.from_google_oauth (user.rb:95) both match on a
bare email string. On the staff surface the risk is low — admins create accounts by hand
from known addresses — but this is a public funnel where anyone can present any Google
identity, and auto-linking is the whole feature. Signup must be stricter than the code
it is modelled on. An unverified email falls through to outcome 4.
Read from auth.info.email_verified, falling back to
auth.extra.raw_info.email_verified; treat absent as false.
The alum who signed up with @belmont.edu and authenticates with a personal Gmail is the
common case, not the edge one. Without the BUID fallback, outcome 4 hands that person a
signup form and they create a second row — the exact duplicate 28.1 just made impossible
to hold and expensive to resolve.
Belmont runs Microsoft, not Google Workspace (confirmed August 14, 2026), which
promotes this from “improves the match rate” to “is the only path”. Those 20 rows cannot
match on email under any circumstances — there is no Google identity behind a belmont.edu
address to match against. Their owners will click with a personal account, and step 3 is
the only thing that connects it to the record they already have.
The portal already proves the mechanism at
cp/champion.rb:1749-1759
(Alumni.filter_by_any_email → BUID → existing record). Use the shared
AlumniLookupService.find(email:) rather than reaching into Cp::.
When step 3 matches, the authenticated address differs from the record’s. Do not update the record’s email. It may be what Advancement Services has, and CRM changes are not recallable once exported (22.3). The Google address is used for authentication and discarded — decided during planning; revisit only if a real need appears.
A google_uid presented while it is already stored on a different active signup: refuse,
do not re-link, and say so plainly. Matches the portal’s handling
(cp/omniauth_callbacks_controller.rb:124).
After 28.1 this should be unreachable — one row per email, and a Google identity carries one email. It gets an explicit branch and a test anyway, because “unreachable” is a claim about code that will change.
add_column :champion_signups, :google_uid, :string
add_column :champion_signups, :google_linked_at, :datetime
add_index :champion_signups, :google_uid,
unique: true, where: "google_uid IS NOT NULL AND deleted_at IS NULL"
Column names deliberately match users and cp_champions. If the tables ever do
converge, the columns already line up — see README §2, Decision A.
Extend to_champion_attributes (champion_signup.rb:471)
to carry google_uid. That is the convergence rule applied: the signup→Champion map gets
extended whenever a signup field gains a Champion equivalent, so a converted signup keeps
its Google link through the transition instead of silently losing it.
Minimal, in the profile hub: provider, the linked address, when it was connected, and a
disconnect action writing google_unlinked.
Unlink is unconditionally safe here and needs none of the portal’s “last credential” logic, because passwordless is permanent — email link and code are always available as a floor. That is a structural consequence of Phase 27 Decision A, not a coincidence, and it is why this screen is cheap on this surface and annoying on the other one.
Linking still happens silently on first Google sign-in. This screen is for visibility and revocation only; it is not a prerequisite for anything.
PRIVACY_POLICY_VERSION goes to the implementation date. Two material changes:
Rows stamped 2026-08-12 accepted neither. Both changes are the kind a reasonable person
might have decided differently about had they seen them — which is what a version stamp
exists to make visible.
| Area | Asserts |
|---|---|
| Resolver | Each of the four outcomes, in isolation and in precedence order |
| Resolver | email_verified: false never matches — falls to outcome 4 even with an exact email match present |
| Resolver | Outcome 3 signs in and leaves email unchanged |
| Resolver | A google_uid on another active row refuses rather than re-links |
| Resolver | Outcome 4 creates no ChampionSignup — assert_no_difference |
| Callback | Both buttons hit one endpoint; destination is decided by match result, not by origin |
| Callback | /auth/signup_google/callback on the signup host does not reach Cp::OmniauthCallbacksController |
| Events | signin_completed carries method: "google"; google_linked written once, not on every subsequent sign-in |
| Connected Accounts | Disconnect writes google_unlinked and leaves the record signed in |
| Policy | Version constant bumped; consent copy names both changes |
Sabotage checks, both required. Force email_verified to true unconditionally and
confirm the verification test fails. Remove the Cp:: host constraint from the route test
and confirm the collision test fails. A routing assertion that passes with the constraint
gone is asserting nothing.
belmont.edu /
bruins.belmont.edu rows can never match on email, and day-one reach stays at ~67%
rather than the ~77% the earlier draft hoped for. See §3.2 — this is what makes the BUID
fallback the only path for that population, not a nicety./sign-up it will sign people in about two-thirds of the time, so “Sign up with Google”
would be wrong more often than right.signup_google path.A Microsoft/Entra provider would cover the belmont.edu population directly, and the
resolver built here makes it genuinely cheap — a config entry, a microsoft_uid column,
and one more branch in the same four-way match. It is out of scope for 28.2 and sits in
BACKLOG beside Apple and Facebook.
Worth stating why it is not urgent: the people holding a belmont.edu address are current
staff and recent grads, and their personal account is what they will click with. §3.2’s
BUID fallback already resolves them — a personal Gmail matching an Alumni record whose
email is the university address lands on the right signup. The Microsoft provider would
save them nothing they cannot already do; it would only matter if someone’s only email
identity were the university one.
Revisit if the miss rate on step 4 turns out to be concentrated in that domain.
| Piece | Where |
|---|---|
| Resolver | app/services/signup/google_identity.rb |
| Callback, failure, disconnect | app/controllers/signup/omniauth_callbacks_controller.rb |
| Second provider | config/initializers/devise.rb |
| Routes | config/routes.rb — callback, disconnect, and the strategy-constrained failure |
| Columns + index | db/migrate/20260814210000_add_google_identity_to_champion_signups.rb |
| Button | app/views/signup/signups/_google_button.html.erb, rendered by sessions/new and steps/_who_you_are |
| Connected Accounts | app/views/signup/signups/show.html.erb sidebar |
| Handoff | Signup::SignupsController#pending_google, #google_prefill, #apply_pending_google, #consume_pending_google |
| Tests | test/services/signup/google_identity_test.rb (18), test/controllers/signup/omniauth_callbacks_controller_test.rb (21), plus model and compliance additions |
Full suite after: 5932 runs, 17145 assertions, 0 failures, 0 errors, 3 skips.
Each of these differs from §1–§9 above. None changes a decision; they are things the spec did not say, or said in a shape the implementation proved wrong.
1. §3.4’s refusal is two shapes, only one is unreachable, and only one refuses.
The spec described a single case — a google_uid already stored on a different
active signup — and called it unreachable after 28.1, correctly. Implementing it
surfaced a second shape that is routinely reachable: the row we resolved
already carries a different google_uid. Link a personal Gmail, then
authenticate later with a second Google account whose address is also on the
alumni record, and both resolve to the same BUID.
Both originally refused, and that was wrong for the second one. Reported the
same day by a real two-account case (@gmail.com signup, @belmont.edu sign-in,
both addresses on one alumni record): the sign-in bounced to /sign-in and the
person had no way forward that wouldn’t loop.
The error was conflating two decisions:
| Warranted? | Why | |
|---|---|---|
| Signing in | Yes | Outcome 3 already signs this person in through the same fallback when no link is present. Making the identical credential insufficient because a link exists treats the link as a security boundary. It isn’t one — it’s a convenience record, and it does not change what the credential proves. |
| Moving the link | No | Repointing it at whoever authenticated most recently is how an identity gets taken over, and it would overwrite a connection somebody made deliberately. |
So §3.4b now signs in, leaves the link alone, and says so, pointing at Connected Accounts. This is §3.3’s rule applied to the second column — a BUID match signs in and leaves the stored email alone for exactly the same reason. §3.4a still refuses, because there the uid and the email name different rows and there is no single record to open.
Residual risk, stated because it is the strongest argument against this:
AlumniLookupService searches every alumni email field including email_other,
so a shared household address there would let a spouse sign in as the alum. That
hole is identical in outcome 3 as originally specified and approved — the
link-state refusal never closed it and was never a mitigation for it. Narrowing
which alumni email fields the fallback trusts is the fix if it matters, and it is
its own decision.
2. Both lookups run even when the uid already matched.
The obvious implementation short-circuits (by_uid || resolve_by_identity), and
that is what was written first. It makes the §3.4a branch dead code that still
reads as protection — the test failed against it, which is the whole reason the
spec asked for a branch and a test rather than a comment. The cost is one indexed
lookup plus at most one alumni email search per sign-in, not per request.
3. Server-side email enforcement was pulled forward from 28.3.
The spec put the prefill contract wholly in 28.3. Shipping the handoff without
the server half would make “prefilled from Google” a suggestion any crafted POST
could ignore, and the Google link would land on a record holding an address
nobody verified. #apply_pending_google overwrites the submitted address with
the verified one. 28.3 still owns the visual lock, the step-1 restructure, and
the status-ladder repair.
4. An unverified address is withheld from the prefill, not just from matching.
§3.1 says an unverified email “falls through to outcome 4” and stops there.
It is also dropped from Result#prefill: putting an address we have no evidence
for into a field 28.3 is about to lock would launder it into looking verified.
The name still prefills, and the uid still attaches on create — the uid is
Google’s account identifier and is trustworthy whatever the address does.
5. /auth/failure needed handling the spec did not mention.
OmniAuth derives its failure endpoint from the path prefix, so both providers
fail to /auth/failure — already claimed by the portal, which redirects to the
Alumni Network login page. A signup visitor declining consent at Google would
have landed in a frozen surface.
Captured for signup_google only, constrained on the strategy param
OmniAuth::FailureEndpoint appends. strategy=google_oauth2 falls through to the
portal handler exactly as before, and a test asserts that negative — without it
the constraint would be decorative. If a future OmniAuth drops the param the
branch stops matching and behavior reverts to today’s: it fails toward the old
path, not into a 404.
6. The button hides itself when the provider is not configured.
The launch guide asked for this to be verified on staging; it is now enforced.
Without GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET the initializer skips the
provider, so there is no middleware at /auth/signup_google and the button would
post into a 404 — a dead control reads as a broken site rather than a feature that
is switched off. Signup::BaseController#signup_google_available? reads Devise’s
own registry rather than re-checking the env vars, so it follows the initializer
if that condition ever changes.
7. A google_uid uniqueness validation, not only the index.
§4 specified the index alone. The validation exists for the same reason 28.1’s
email one does: the common case should be a form error rather than a
RecordNotUnique in front of an alum. It is nearly unreachable — the resolver
refuses first — and catches the race between two simultaneous callbacks.
8. google_linked carries matched_by alongside auto.
§7 specified auto: true | false. matched_by records which of the four
branches found the record (google_uid, email, buid, signup_created). The
BUID one is the interesting one and the one §6 had to disclose; being able to
count it is what makes that disclosure auditable rather than asserted.
9. Connected Accounts renders only when something is linked.
§5 said “minimal, in the profile hub”. There is no empty state: connecting happens by signing in with Google, not from this card, so an empty version would be chrome advertising an action it cannot perform.
10. layouts/signup had to learn to render flash.
Not in the spec because nothing on this surface had ever set one — every message
here was a page-level banner rendered by the view that owned it. An OAuth
callback has no page of its own, so it can only redirect and hand the reason
forward, and this was the only layout in the app with nowhere for that to land.
The refusal above reached the user as a silent bounce. See
/debug → “A Redirect-Only Controller on a Surface Whose Layout Has No Flash”.