Skip to main content

Quote Follow-Up — authentication (the magic link)

Audience: developers & AI agents · Scope: the no-login, tokenized authorization for the Quote Follow-Up landing page — the grant token, how it's minted (incl. the machine Link API), how it's validated, and endpoint gating · Last reviewed: 2026-07-31

A spoke of the Quote Follow-Up System; it authorizes the page in landing-page.md. For the external-caller view of the Link API, see the consumer guide link-api-n8n.md.

TL;DR — The follow-up email carries a signed grant token (a "magic link") that authorizes the visitor to update that agent's own follow-up quotes — it deliberately does not log them into their Joomla account, because these emails may be forwarded to untrusted third parties. The token is a stateless, HMAC-signed scoped capability: it identifies the agent and unlocks the follow-up tool for their quotes, nothing else. A visitor without a valid token (and no session) is sent to the normal Joomla login and returned to the page.


Why not a login? (the scoped capability)

These follow-up emails get forwarded to untrusted third parties. A magic link must therefore not hand the holder the agent's Joomla/ConfigBox account. So the token is a scoped capability, not a login: it identifies the agent (so we know whose feedback we're recording) and unlocks the follow-up tool for that agent's own quotes — and nothing else on the site. No My Quotes, no checkout, no account area.

Files

All paths are under docroot/components/com_configbox/data/customization/.

FileRole
system_overrides/BcQuoteFollowupAuth.phpThe landing page's authorization: signs/verifies the stateless token (generateToken() / resolveGrant() / resolveUserId(); resolveUserIdForSerial() for the link-integrity check), starts a user-scoped grant cookie for the agent (startGrantFromToken(), getGrantUserId(), clearGrant()) without any Joomla login, and builds links (generateLink() / buildLandingUrl()) and the Joomla login redirect (buildLoginRedirectUrl()). Reads the configurable lifetime.
controllers/bcquotefollowuplink.phpConfigboxControllerBcquotefollowuplink. The machine-to-machine Link API (task getLink, pretty path /quote-follow-up-link/getLink/<serial>): an external automation requests a serial (+ optional shared secret) and gets back a tokenized magic link for the quote's agent. Wraps BcQuoteFollowupAuth::generateLink(); getSegmentMatching() drives the SEF routing.

The gating and grant checks are enforced inside the landing-page controller (bcquotelandingpage.php display() / saveUpdate()) — see landing-page.md.

Authorization flow (display())

The page authorizes the visitor to act on the acting agent's own quotes, either via a magic-link grant or via a real logged-in session. Authorization is handled by BcQuoteFollowupAuth (a standalone class in system_overrides/, auto-loaded on every request) and enforced at the top of display():

  1. Valid tokenBcQuoteFollowupAuth::startGrantFromToken() mints a user-scoped, HttpOnly grant cookie (bc_qf_grant) identifying the agent — no Joomla login, and the ConfigBox session user_id is left untouched — then the request is redirected to the clean, token-free URL (so the token doesn't linger in history or the Referer and can't be replayed). Every later request (the clean URL, the AJAX save) re-reads the cookie via getGrantUserId(). The link's token must be signed for the serial in its URL — but that is a link-integrity check at grant-start, not a limit on the grant's later scope.
  2. Wrong or expired token, and no grant/session → the "link no longer valid" deny screen (displayInvalidLink() → the expired state), which offers to email a fresh magic link or to log in directly (a login button that returns here after sign-in, for a portal user). No quote detail is shown; a wrong token and an expired token are handled identically. A stale token on top of an existing valid session is ignored — the signed-in owner still sees their quote.
  3. No token, no grant and no session → redirected to the Joomla login with a return back to this landing page (buildLoginRedirectUrl()). This is a fallback for a portal user, who can sign in normally and then be authorized by session ownership (below). The magic-link path never logs anyone in.
  4. The quote must belong to the acting agent. A grant authorizes any quote whose user_id is the grant's agent (display() checks quote->user_id === getGrantUserId()). A real session must own the quote (ConfigboxModelBcquotes::quoteBelongsToUser(); the quote's user_id is the agent it was made for). A quote belonging to a different user (a grant or session for someone else) is shown the exact same notfound screen (and 404) as a genuinely missing quote — so nobody can probe which serials exist.

Scope — the agent's own quotes, inside the tool

A grant is user-scoped: it lets the agent review and update any of their own open quotes from within the follow-up tool. So the "your other open quotes" tiles are shown to a grant too (display() passes setOtherQuotesAllowed($sessionOwns || $grantOwns)), and those tiles link to /quote-follow-up/<other-serial> with no token — the grant cookie authorizes them because they belong to the same agent. What a grant does not reach: another agent's quotes (the per-quote user_id check sends those to notfound), and anything outside the tool. In particular the "View in My Quotes" deep link goes to the logged-in portal, so it renders only for a real session (setMyQuotesLinkAllowed($sessionOwns)) — a not-logged-in grant visitor could not use it anyway.

Endpoint gating

Both write tasks — saveUpdate and confirmNoChange (the one-tap "Nothing has changed" confirmation) — are gated by requestMayActOnQuote(): a grant cookie whose agent owns this quote, or a real signed-in session that owns it; otherwise they return a JSON error and change nothing. (A no-change confirmation still writes — it bumps pipedrive_agent_feedback_updated_on — so it is gated exactly like a real update.) requestNewLink is intentionally NOT gated — it is the recovery path for a visitor whose token is wrong/expired (so they have no grant yet), and it is safe because it only ever emails a fresh magic link to the quote's own agent (the address on file, never a request-supplied one) and returns just a masked address.

The token (stateless and signed)

There is no token table. The token is base64url(payload) . "." . base64url(hmac) where payload = "v2:<cbUserId>:<base64url(serial)>:<issuedAt>" and the HMAC-SHA256 is keyed with the Joomla site secret (so dev/staging/live sign differently and nothing extra needs storing). Authorization keys on the signed <cbUserId> (user-scoped); the <serial> is the link's target and a start-time integrity check, not a limit on scope. The grant cookie holds a token of the same shape (a freshly signed one), so it is validated exactly like a link token.

Expiry is configurable. resolveGrant() checks the token's age against quote_followup_token_lifetime_days (see Admin settings; added by updates/0.5.53.php, default 5 days, 0 = never expires). Because the age is checked at validation time (not baked into the token), changing the setting takes effect immediately for every outstanding link and grant cookie. The cookie itself is a session cookie (dies with the browser); the token age check is the hard expiry.

How a link reaches an agent is intentionally open. Any sender builds one with:

// Absolute, SEF, token-carrying landing URL for a user + quote:
$url = BcQuoteFollowupAuth::generateLink($cbUserId, $serial);
// or the signed token alone:
$token = BcQuoteFollowupAuth::generateToken($cbUserId, $serial);

Three consumers are wired up today: the expired-screen "Request a new link" flow (ConfigboxModelBcquotelandingpage::sendNewLink), which emails a fresh link to the quote's own agent; the Link API, which hands a link to an external automation; and the nudge sheet, whose every row carries a fresh link (follow_up_link) for the n8n reminder workflow — that is the outbound reminder cadence.

Handing this to an integrator? link-api-n8n.md is the self-contained, consumer-facing guide (endpoint, auth, response, n8n node setup) meant to be shared outside the team. The section below is the internal/architectural view.

A tiny machine-to-machine endpoint so an external automation (e.g. an n8n workflow) can fetch a follow-up magic link for a quote — for instance to include it in an outbound email/SMS it sends itself. It just wraps BcQuoteFollowupAuth::generateLink(), so the link it returns is identical to the one the "request a new link" flow emails: minted for the quote's own agent (user_id) and, when opened, starting a user-scoped grant — not a login (see the authorization flow above).

Endpoint (ConfigboxControllerBcquotefollowuplink::getLink) — GET or POST. Pretty SEF path (published by updates/0.5.71.php as a system-seo menu item, alias quote-follow-up-link):

https://betacalco.com/quote-follow-up-link/getLink/<serial>

The serial may be the last path segment (above), a query/form param (…/getLink?serial=<serial>), or a JSON body { "serial": "<serial>" }. The raw form index.php?option=com_configbox&controller=bcquotefollowuplink&task=getLink&serial=<serial> also works (the SEF item is just a prettier alias). Path routing is handled by the controller's getSegmentMatching() (segment 0 → task, segment 1 → serial).

Auth — a shared secret, fail-open when unconfigured. Set the "Follow-up link API secret" (quote_followup_api_secret, see Admin settings) to a long random string, then send it on each request in the X-Bc-Api-Key header (preferred — maps to n8n's Header Auth credential) or as Authorization: Bearer <secret>. A request/JSON secret field is accepted as a last resort but discouraged (it leaks into access logs). The secret is compared constant-time. While the setting is blank the endpoint is open (unauthenticated) — the same fail-open convention as the Pipedrive webhook; setting the secret is what locks it down. The fail-open decision lives in the controller; BcQuoteFollowupAuth::apiSecretMatches() is the pure constant-time matcher.

Responses — proper HTTP status codes with a consistent JSON envelope on every response:

StatusBodyWhen
200{ success:true, serial, url, tokenLifetimeDays, expiresAt }Link minted. url is the absolute SEF landing URL with ?token=. tokenLifetimeDays is the configured lifetime (0 = never); expiresAt is now + lifetime (ISO-8601) or null.
400{ success:false, errors:["Missing quote serial."] }No serial supplied.
401{ success:false, errors:["Unauthorized."] }A secret is configured and the request's didn't match (or was absent). When no secret is set this never happens.
404{ success:false, errors:["No quote found for that serial."] }Serial doesn't resolve, or the quote has no agent (a link would authorize nobody).
500{ success:false, errors:["Could not look up that quote right now."] } (or "Could not generate a link right now.")The serial lookup threw, or link signing failed. Both are logged under bcquotelandingpage.

expiresAt caveat. The token's age is re-checked against the current lifetime setting at validation time, not baked in — so lowering quote_followup_token_lifetime_days later shortens real expiry for already-issued links. expiresAt is the expiry as of issuance.

n8n gotcha. The regular HTTP Request node routes non-2xx to the error branch (usually what you want — a 404 is a real "no such quote"). The AI-agent HTTP Request Tool node instead swallows the response body on non-2xx; if that's the consumer, enable "Never Error" and branch on the success field. The body shape is identical on success and failure, so either style works.

The endpoint never logs the returned url/token (a bearer capability) — only the serial and outcome, under the bcquotelandingpage log type.

Admin settings

SettingWhere setDefaultEffect of blank / off
quote_followup_token_lifetime_daysadmin → ConfigBox settings → Quote Follow-ups (stored in #__configbox_external_settings_quote_follow_up)5Magic-link token lifetime in days. 0 = never expires. Checked at validation time, so a change applies immediately to outstanding links + grant cookies.
quote_followup_api_secretsame group(blank)Shared secret authenticating the Link API. Blank = the API is open (unauthenticated, fail-open like the Pipedrive webhook). Set a long random string to require it.

(The plain-language operator how-to lives in the separate Admin Guide (admin-guide/quote-follow-up/README.md).)

Deployment runbook (manual steps)

Ships with the code; these are the one-time setup steps (run in order):

  1. Migrations run automatically on the next page load: 0.5.53 (adds quote_followup_token_lifetime_days), 0.5.70 (adds quote_followup_api_secret), 0.5.71 (publishes the quote-follow-up-link SEF menu item for the Link API). No manual DDL. See migrations.md.
  2. Set the token lifetime if the 5-day default isn't right (admin → Quote Follow-ups).
  3. Lock down the Link API if it's used by external automation: set quote_followup_api_secret to a long random string and give the same value to the caller (n8n Header Auth, X-Bc-Api-Key). Leave blank only if the endpoint isn't exposed.
  4. Smoke test: open a valid magic link → it starts the grant and strips the token; hit the Link API for a known serial → 200 with a url. Turn the API off by clearing the secret (open) — there is no separate on/off switch; the feature is on wherever the landing page is.

Testing

The auth gate and the Link API are covered by tests/specs/anon/quote-follow-up-auth.spec.ts, quote-follow-up-access.spec.ts, and quote-follow-up-link-api.spec.ts; specs mint a grant token with support.mintFollowupToken(serial). See the test catalog and landing-page.md → Testing.