Skip to content

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). See HEALTHCAREINSURANCE.md for 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:

  1. Agent initiates from HealthInsurance.com. An agent is on a call with a member inside the HealthInsurance.com app and clicks the EasyOTC launcher.

  2. HealthInsurance.com builds the OAuth request. They base64-encode a JSON blob of agent + member + policy fields and place it in the OAuth state parameter. The blob fields are listed in the Validator at app/Http/Actions/OAuth/HealthcareInsuranceCallbackAction.php:107-134.

  3. HealthInsurance.com runs the OAuth authorization. Because the partner's OAuth provider is built on Google OAuth 2.0 (token_url defaults to https://oauth2.googleapis.com/token, see config/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.

  4. 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 is https://api.easyotc.com; staging is https://stage-api.easyotc.com (config/services.php:50, default HEALTHCAREINSURANCE_REDIRECT_URI).

  5. HealthcareInsuranceCallbackAction validates the inbound params.

    • Logs the full incoming URL and query (HealthcareInsuranceCallbackAction.php:40-43).
    • Returns 400 missing_code / 400 missing_state if either query param is absent (:52-60).
    • Note (current state, Jan 2026): the actual codeaccess_token exchange against the partner's token endpoint is commented out at HealthcareInsuranceCallbackAction.php:69-94. In production today we accept the inbound code without exchanging it, and trust the state payload. Re-enabling the exchange is a known follow-up — details TBD on whether the partner is currently issuing usable codes.
  6. Decode + validate state. base64_decode + json_decode at :97. If decoding fails: 400 invalid_state. Otherwise the payload is run through a Validator (:107-134) that requires agent_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 returns 400 invalid_state_data with the validator errors.

  7. Upsert agent. User::where('email', agent_email)->first(); if missing, create with a random bcrypt password and the configured carrier_id, then assign role agent (:159-175).

  8. Upsert member. Looked up by Member::where('code', member_code). If missing:

    • The underlying User row is found by member_email (or, if blank, a synthesized firstname.lastname@medicareinsurance.com fallback — :182-184) and created with role member if needed.
    • member_date_of_birth is parsed with Carbon::parse(...)->format('Y-m-d'); partner currently sends "10/22/1948 12:00:00 AM" format (:199-209).
    • A Member row is created with code, 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.
  9. Upsert policy (optional). If policy_id is present, Carrier::firstOrCreate(['name' => policy_carriername]) and Policy::updateOrCreate(['external_id' => policy_id], [...]) (:232-267). Unlike the member, the policy IS re-synced on every login (updateOrCreate).

  10. Mint one-time SSO token. SsoLogin::create with a 64-char Str::random token, the agent + member ids, and expires_at = now + 60 seconds (:286-291).

  11. 302 redirect to the frontend.Location: {FRONTEND_URL}/auth/callback?token={64char}&agent_id={id}&member_id={id} (:293-299). FRONTEND_URL defaults to http://localhost:3000 (config/services.php:44).

  12. Frontend exchanges the token. pages/auth/callback.vue on the storefront reads ?token= from the URL and POSTs it to /api/sso/exchange (pages/auth/callback.vue:21-36). Only token is sent — agent_id/member_id from the redirect are ignored by the frontend.

  13. ExchangeSsoTokenAction consumes 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) at ExchangeSsoTokenAction.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 impersonation activity via AuditService::logUserAction (:70-75).
    • Responds with { data: { user, member, agent_token, member_token, impersonated_by } } (:82-90).
  14. Frontend stores tokens, broadcasts session, lands on /products. authStore.setToken(agent_token), authStore.impersonationToken = member_token, authStore.member = member, broadcastSsoSessionChange(member.id) (writes localStorage.sso_session_change so other open tabs reload — composables/useSsoSession.ts:6-9), then navigateTo('/products') (pages/auth/callback.vue:44-56).

Endpoints / actions

MethodPathAction classPurpose
GET/api/oauth/healthcareinsurance/callbackApp\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/exchangeApp\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-callbackApp\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.tsbroadcastSsoSessionChange() / useOnSsoSessionChange() for cross-tab reloads.
  • types/user.ts:75-83SsoExchangeResponse type definition.

Data model

Table: sso_logins (database/migrations/2026_01_30_100024_create_sso_logins_table.php)

ColumnTypeNotes
idbigint PK
tokenstring, unique64-char random string from Str::random(64)
agent_idFK → users.idcascadeOnDelete
member_idFK → members.idcascadeOnDelete
expires_attimestampSet to now() + 60 seconds on create
used_attimestamp, nullableStamped by ExchangeSsoTokenAction → enforces single-use
created_at / updated_attimestamps

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 users row, role agent, carrier_id = HEALTHCAREINSURANCE_CARRIER_ID (default 1), random bcrypt password.

Created on first login for a given member_code:

  • One users row for the member (role member), email = member_email or fallback firstname.lastname@medicareinsurance.com.
  • One members row keyed on code, with date_of_birth, phone, address, city, state, zip_code, is_active=true, plan_id=1.
  • If policy_id was supplied: one policies row (keyed on external_id) and possibly one carriers row (firstOrCreate by name).

On subsequent logins:

  • The users and members rows are looked up but not updated — the action only fires create inside the if (!$member) branch (HealthcareInsuranceCallbackAction.php:180-230). Address / DOB / phone changes from the partner will not be reflected on existing members.
  • The policies row is re-synced every time via Policy::updateOrCreate(['external_id' => policy_id], ...) (:252-261).
  • A fresh sso_logins row is always created.

Config / secrets

Defined in config/services.php:46-52:

Env varRequiredDefaultNotes
HEALTHCAREINSURANCE_CLIENT_IDyesOAuth client id issued by partner (Google OAuth project easyotc-sso).
HEALTHCAREINSURANCE_CLIENT_SECRETyesOAuth client secret. Currently logged in plaintext at HealthcareInsuranceCallbackAction.php:63 — should be redacted.
HEALTHCAREINSURANCE_TOKEN_URLnohttps://oauth2.googleapis.com/tokenToken endpoint. Only used when the (currently commented-out) code-exchange block at :69-94 is re-enabled.
HEALTHCAREINSURANCE_REDIRECT_URIyeshttps://stage-api.easyotc.com/api/oauth/healthcareinsurance/callbackMust exactly match what the partner has registered with Google.
HEALTHCAREINSURANCE_CARRIER_IDno1carrier_id stamped on auto-created agent + member users rows.
FRONTEND_URLyes (effectively)http://localhost:3000Used 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.

TriggerHTTPerror codeLog lineWhat the user sees
?code missing400missing_codeHealthcareInsurance OAuth: Missing authorization code (:53)JSON error in browser.
?state missing400missing_stateHealthcareInsurance OAuth: Missing state parameter (:58)JSON error in browser.
state is not valid base64 JSON400invalid_stateHealthcareInsurance OAuth: Invalid state parameter (with raw state) (:100-103)JSON error.
Required state fields missing / wrong types400invalid_state_dataHealthcareInsurance OAuth: Invalid state data with validator errors (:137-140)JSON error, includes per-field errors.
DB error during agent/member/policy upsert500server_errorHealthcareInsurance 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 partner401invalid_code / token_failedSee :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).

TriggerHTTP from /sso/exchangeerror codeWhat 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 used401invalid_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 User500server_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.