HealthInsurance.com SSO
Summary
EasyOTC accepts inbound SSO from HealthInsurance.com (a.k.a. "Healthcare Insurance") so that licensed agents can land a member on the EasyOTC storefront already authenticated and impersonating that member. The handoff is built on top of Google OAuth 2.0: HealthInsurance.com drives the OAuth dance, redirects to our callback with code + state, we decode the state (base64-encoded JSON containing agent + member + policy fields), upsert the agent / member / policy, mint a short-lived one-time SsoLogin token, and bounce the browser to the Nuxt frontend which exchanges the token for an agent bearer token plus a member impersonation token.
Naming note: the upstream partner is referred to interchangeably as "healthinsurance.com" and "Healthcare Insurance" in their docs. In our codebase everything is namespaced
healthcareinsurance(config key, route segment, action class). SeeHEALTHCAREINSURANCE.mdfor the partner-facing version of this document.
Flow
End-to-end, from an agent clicking "shop OTC" on HealthInsurance.com to the member-impersonated storefront on easyotc.com:
Agent initiates from HealthInsurance.com. An agent is on a call with a member inside the HealthInsurance.com app and clicks the EasyOTC launcher.
HealthInsurance.com builds the OAuth request. They base64-encode a JSON blob of agent + member + policy fields and place it in the OAuth
stateparameter. The blob fields are listed in the Validator atapp/Http/Actions/OAuth/HealthcareInsuranceCallbackAction.php:107-134.HealthInsurance.com runs the OAuth authorization. Because the partner's OAuth provider is built on Google OAuth 2.0 (
token_urldefaults tohttps://oauth2.googleapis.com/token, seeconfig/services.php:49), the agent ends up authenticating via the partner's Google-backed flow. EasyOTC is the OAuth client; we do not initiate the authorization request.Partner redirects to our callback. Browser hits
GET https://{api-host}/api/oauth/healthcareinsurance/callback?code={authcode}&state={base64json}. Route:routes/api.php:90. Production callback host ishttps://api.easyotc.com; staging ishttps://stage-api.easyotc.com(config/services.php:50, defaultHEALTHCAREINSURANCE_REDIRECT_URI).HealthcareInsuranceCallbackActionvalidates the inbound params.- Logs the full incoming URL and query (
HealthcareInsuranceCallbackAction.php:40-43). - Returns
400 missing_code/400 missing_stateif either query param is absent (:52-60). - Note (current state, Jan 2026): the actual
code→access_tokenexchange against the partner's token endpoint is commented out atHealthcareInsuranceCallbackAction.php:69-94. In production today we accept the inboundcodewithout exchanging it, and trust thestatepayload. Re-enabling the exchange is a known follow-up — details TBD on whether the partner is currently issuing usable codes.
- Logs the full incoming URL and query (
Decode + validate
state.base64_decode+json_decodeat:97. If decoding fails:400 invalid_state. Otherwise the payload is run through aValidator(:107-134) that requiresagent_email,agent_first_name,agent_last_name,member_code,member_first_name,member_last_name,member_date_of_birth; everything else (member address fields, phone, policy fields) is optional. Failed validation returns400 invalid_state_datawith the validator errors.Upsert agent.
User::where('email', agent_email)->first(); if missing, create with a random bcrypt password and the configuredcarrier_id, then assign roleagent(:159-175).Upsert member. Looked up by
Member::where('code', member_code). If missing:- The underlying
Userrow is found bymember_email(or, if blank, a synthesizedfirstname.lastname@medicareinsurance.comfallback —:182-184) and created with rolememberif needed. member_date_of_birthis parsed withCarbon::parse(...)->format('Y-m-d'); partner currently sends"10/22/1948 12:00:00 AM"format (:199-209).- A
Memberrow is created withcode,user_id,date_of_birth,phone,address(=member_homeaddressline1),city,state(=member_statecode),zip_code,is_active=true,plan_id=1(:212-223). - Existing members are not updated on subsequent logins — first-login wins.
- The underlying
Upsert policy (optional). If
policy_idis present,Carrier::firstOrCreate(['name' => policy_carriername])andPolicy::updateOrCreate(['external_id' => policy_id], [...])(:232-267). Unlike the member, the policy IS re-synced on every login (updateOrCreate).Mint one-time SSO token.
SsoLogin::createwith a 64-charStr::randomtoken, the agent + member ids, andexpires_at = now + 60 seconds(:286-291).302 redirect to the frontend.
Location: {FRONTEND_URL}/auth/callback?token={64char}&agent_id={id}&member_id={id}(:293-299).FRONTEND_URLdefaults tohttp://localhost:3000(config/services.php:44).Frontend exchanges the token.
pages/auth/callback.vueon the storefront reads?token=from the URL and POSTs it to/api/sso/exchange(pages/auth/callback.vue:21-36). Onlytokenis sent —agent_id/member_idfrom the redirect are ignored by the frontend.ExchangeSsoTokenActionconsumes the token.- Looks up via the
valid()scope: matching token,used_at IS NULL,expires_at > now()(app/Models/SsoLogin.php:34-40). Failure →401 invalid_token. - Marks the row
used_at = now()(single-use) atExchangeSsoTokenAction.php:39. - Issues a Passport bearer token for the agent:
$agent->createToken(RoleEnum::AGENT->value)->accessToken(:58). - Ends any prior impersonation on the member's user (
:62-64), then$agent->impersonate($memberUser)to mint a member impersonation token (:67). - Logs an
impersonationactivity viaAuditService::logUserAction(:70-75). - Responds with
{ data: { user, member, agent_token, member_token, impersonated_by } }(:82-90).
- Looks up via the
Frontend stores tokens, broadcasts session, lands on
/products.authStore.setToken(agent_token),authStore.impersonationToken = member_token,authStore.member = member,broadcastSsoSessionChange(member.id)(writeslocalStorage.sso_session_changeso other open tabs reload —composables/useSsoSession.ts:6-9), thennavigateTo('/products')(pages/auth/callback.vue:44-56).
Endpoints / actions
| Method | Path | Action class | Purpose |
|---|---|---|---|
GET | /api/oauth/healthcareinsurance/callback | App\Http\Actions\OAuth\HealthcareInsuranceCallbackAction (routes/api.php:90) | Inbound landing from HealthInsurance.com. Decodes state, upserts agent/member/policy, creates a one-time SsoLogin, 302s to {FRONTEND_URL}/auth/callback?token=.... |
POST | /api/sso/exchange | App\Http\Actions\OAuth\ExchangeSsoTokenAction (routes/api.php:91) | Consumes the one-time token, marks it used, issues an agent bearer token + member impersonation token. Called by the Nuxt frontend, not by the partner. |
GET | /api/sso/test-callback | App\Http\Actions\OAuth\TestSsoCallbackAction (routes/api.php:92) | Local/staging-only shortcut that simulates the partner callback. Disabled in production (TestSsoCallbackAction.php:29-35). Takes ?agent_email=&member_code=, requires both records to already exist, creates an SsoLogin, and redirects to {FRONTEND_URL}/auth/callback?token=.... |
Frontend (Nuxt, storefront repo):
pages/auth/callback.vue— receives?token, calls/api/sso/exchange, hydrates auth store, navigates to/products.composables/useSsoSession.ts—broadcastSsoSessionChange()/useOnSsoSessionChange()for cross-tab reloads.types/user.ts:75-83—SsoExchangeResponsetype definition.
Data model
Table: sso_logins (database/migrations/2026_01_30_100024_create_sso_logins_table.php)
| Column | Type | Notes |
|---|---|---|
id | bigint PK | |
token | string, unique | 64-char random string from Str::random(64) |
agent_id | FK → users.id | cascadeOnDelete |
member_id | FK → members.id | cascadeOnDelete |
expires_at | timestamp | Set to now() + 60 seconds on create |
used_at | timestamp, nullable | Stamped by ExchangeSsoTokenAction → enforces single-use |
created_at / updated_at | timestamps |
Model: app/Models/SsoLogin.php. Scope valid($token) returns rows where token matches, used_at IS NULL, and expires_at > now() (SsoLogin.php:34-40). markAsUsed() sets used_at = now().
Created on first login for a given agent_email:
- One
usersrow, roleagent,carrier_id = HEALTHCAREINSURANCE_CARRIER_ID(default1), random bcrypt password.
Created on first login for a given member_code:
- One
usersrow for the member (rolemember), email =member_emailor fallbackfirstname.lastname@medicareinsurance.com. - One
membersrow keyed oncode, withdate_of_birth,phone,address,city,state,zip_code,is_active=true,plan_id=1. - If
policy_idwas supplied: onepoliciesrow (keyed onexternal_id) and possibly onecarriersrow (firstOrCreatebyname).
On subsequent logins:
- The
usersandmembersrows are looked up but not updated — the action only firescreateinside theif (!$member)branch (HealthcareInsuranceCallbackAction.php:180-230). Address / DOB / phone changes from the partner will not be reflected on existing members. - The
policiesrow is re-synced every time viaPolicy::updateOrCreate(['external_id' => policy_id], ...)(:252-261). - A fresh
sso_loginsrow is always created.
Config / secrets
Defined in config/services.php:46-52:
| Env var | Required | Default | Notes |
|---|---|---|---|
HEALTHCAREINSURANCE_CLIENT_ID | yes | — | OAuth client id issued by partner (Google OAuth project easyotc-sso). |
HEALTHCAREINSURANCE_CLIENT_SECRET | yes | — | OAuth client secret. Currently logged in plaintext at HealthcareInsuranceCallbackAction.php:63 — should be redacted. |
HEALTHCAREINSURANCE_TOKEN_URL | no | https://oauth2.googleapis.com/token | Token endpoint. Only used when the (currently commented-out) code-exchange block at :69-94 is re-enabled. |
HEALTHCAREINSURANCE_REDIRECT_URI | yes | https://stage-api.easyotc.com/api/oauth/healthcareinsurance/callback | Must exactly match what the partner has registered with Google. |
HEALTHCAREINSURANCE_CARRIER_ID | no | 1 | carrier_id stamped on auto-created agent + member users rows. |
FRONTEND_URL | yes (effectively) | http://localhost:3000 | Used to build the final redirect to the Nuxt storefront's /auth/callback. |
There is no dedicated HEALTHINSURANCE_SSO_SECRET / signed-JWT secret. The state blob is base64-encoded JSON, not signed — trust is currently derived from (a) the OAuth code (intended to be exchanged with the partner's token endpoint, but the exchange is presently commented out) and (b) the redirect URI being HTTPS to a registered Google OAuth client. Adding a signed/HMAC state is a known hardening item — details TBD.
Failure modes
All failures inside HealthcareInsuranceCallbackAction return JSON via errorResponse() (HealthcareInsuranceCallbackAction.php:305-312) with shape { success: false, error, message } and the HTTP status below. The user sees raw JSON unless the partner's launcher intercepts it — this is currently a rough edge.
| Trigger | HTTP | error code | Log line | What the user sees |
|---|---|---|---|---|
?code missing | 400 | missing_code | HealthcareInsurance OAuth: Missing authorization code (:53) | JSON error in browser. |
?state missing | 400 | missing_state | HealthcareInsurance OAuth: Missing state parameter (:58) | JSON error in browser. |
state is not valid base64 JSON | 400 | invalid_state | HealthcareInsurance OAuth: Invalid state parameter (with raw state) (:100-103) | JSON error. |
Required state fields missing / wrong types | 400 | invalid_state_data | HealthcareInsurance OAuth: Invalid state data with validator errors (:137-140) | JSON error, includes per-field errors. |
| DB error during agent/member/policy upsert | 500 | server_error | HealthcareInsurance OAuth: Failed to create agent/member with exception message (:272-274), DB transaction rolled back. | JSON error. |
Unparseable member_date_of_birth | — (non-fatal) | — | HealthcareInsurance OAuth: Could not parse date_of_birth warning (:204-207); member is still created with date_of_birth = null. | Login still succeeds. |
Unparseable policy_effectivedate | — (non-fatal) | — | HealthcareInsurance OAuth: Could not parse policy effective date warning (:239-241). | Login still succeeds. |
| (Commented-out today) Token exchange failure with partner | 401 | invalid_code / token_failed | See :80-94 — currently unreachable because the block is disabled. | N/A while disabled. |
On the frontend (pages/auth/callback.vue), failures from POST /sso/exchange are caught and rendered as a "Login Failed" card with a "Go to Login" link to /member/login (callback.vue:58-66, :86-103).
| Trigger | HTTP from /sso/exchange | error code | What the user sees |
|---|---|---|---|
token missing from URL | — (frontend short-circuits) | — | "Missing SSO token. Please try logging in again from your provider." (callback.vue:23-26) |
| Token not found, expired (>60s old), or already used | 401 | invalid_token | "Invalid, expired, or already used token." or the server message; rendered in the red card. Server logs SSO token exchange failed: invalid, expired, or already used token (ExchangeSsoTokenAction.php:30). |
Member row has no associated User | 500 | server_error | "Member does not have an associated user account." (ExchangeSsoTokenAction.php:46-55). Should not happen in practice — the callback always creates a member-user pair. |
"Member not found / blocked" is not currently a distinct failure mode in the inbound callback — if the partner sends a member_code we've never seen, we silently auto-create it. There is no allowlist / blocklist check on either agent_email or member_code. The Member.is_active flag is set to true on create and is not consulted during the SSO flow — details TBD on whether downstream middleware blocks inactive members after login.