Last updated: 2026-04-16
Everything we learned getting IDP Single Sign-On working for Play across development and production. This document exists so no one has to rediscover these issues.
Mobile App (Play)
│
│ 1. Opens in-app browser (WebBrowser.openAuthSessionAsync) to IDP
▼
IDP (id.parler.com)
│ - Checks for existing session (idp_token cookie)
│ - If session exists → encrypts tokens with AES-256-GCM using client_secret
│ - Redirects to: play.parler.com/idp/callback#action=ok&payload=<encrypted>
▼
Vue Web App (play.parler.com — Vercel)
│ - IDPCallback.vue parses fragment params
│ - POSTs encrypted payload to Go backend
▼
Laravel Proxy (play-api.parler.com — CPLN playtv-production)
│ - GoApiProxyController checks GO_API_ACTIVE
│ - Forwards POST to GO_API_BASE_URL
▼
Go Backend (social-production GVC — CPLN)
│ - Decrypts payload using per-app client_secret
│ - Verifies JWT (signature, expiry, issuer)
│ - Mints Passport access_token + refresh_token
▼
Vue Web App
│ - Builds deep link: playtv://auth/callback?access_token=...
│ - window.location.href = deep link
▼
Mobile App
│ - Receives tokens via deep link
│ - User is logged in
| Repo | Role |
|---|---|
parler-identity-service |
IDP — issues tokens, encrypts payloads |
playtv-vue-ui |
Web frontend — IDPCallback.vue handles the redirect |
go-social-api |
Go backend — decrypts payload, verifies JWT, mints Passport tokens |
social-api |
Laravel — proxies /v3/* routes to Go backend |
play-phoenix-mobile |
React Native app — opens CCT, receives deep link |
| Endpoint | Handler |
|---|---|
GET /idp/callback |
Vue IDPCallback.vue (Vercel) |
POST /v3/playtv/authenticate |
Go idp_authenticate.go (via Laravel proxy) |
POST /playtv/login/idp |
Go idp_verify.go (IDP JWT exchange, called by mobile app) |
These tell the Laravel GoApiProxyController where to forward /v3/* requests:
GO_API_ACTIVE=true
GO_API_BASE_URL=https://go-social-api-m351t11btasrr.da11-prod-1.controlplane.us
Without GO_API_ACTIVE=true, all /v3/* requests return 503.
CRITICAL: GO_API_BASE_URL must point to the PRODUCTION Go backend. If it
points to dev, payloads encrypted with the prod secret will fail to decrypt
because dev has a different secret. This was a real bug that took hours to find.
Per-app IDP config uses a naming convention with app-scoped overrides:
# Global fallback (used if no app-specific override exists)
IDP_CLIENT_SECRET=<global-secret>
IDP_ISSUER=https://id.parler.com
IDP_JWKS_URL=https://id.parler.com/.well-known/jwks.json
IDP_LOGIN_ENABLED=true
# Per-app overrides (suffix: _PLAYTV or _PARLER)
IDP_CLIENT_SECRET_PLAYTV=<play-client-secret>
IDP_CLIENT_SECRET_PARLER=<parler-client-secret>
The Go backend resolves config per request based on the URL path:
/v3/playtv/*→ usesIDP_*_PLAYTVvars, falls back toIDP_*/v3/parler/*→ usesIDP_*_PARLERvars, falls back toIDP_*
See go-social-api/internal/authapi/social_config.go for the full resolution logic.
The Vue app needs to know where to POST the encrypted payload:
VITE_API_HOST=https://play-api.parler.com # production, stage
VITE_API_HOST=https://api.playforte.ws # development (non-prod/stage uses api.<domain>)
VITE_IDP_BASE_URL=https://id.parler.com # production only
VITE_IDP_BASE_URL=https://id.dev.parler.com # all non-production branches
CRITICAL: This is baked at BUILD TIME by Vite. Changing it in Vercel requires a redeploy. The first redeploy after changing env vars must have "Use build cache" UNCHECKED, or the old value persists.
IDP encrypts the token payload before sending it in the redirect URL. The Go backend decrypts it using the same client_secret.
Both IDP and Go backend use identical logic:
func deriveKey(secret []byte) []byte {
if len(secret) == 32 {
return secret // Use raw bytes if exactly 32 bytes (AES-256 key size)
}
h := sha256.Sum256(secret)
return h[:] // SHA-256 hash otherwise
}The client_secret comes from the IDP database (platforms.client_secret column).
It MUST match between IDP and Go backend env vars. Verify with:
-- On IDP database
SELECT p.name, p.client_id, p.client_secret
FROM platforms p
WHERE p.name ILIKE '%play%';The encrypted payload is: base64(nonce + ciphertext + tag) where:
- nonce: 12 bytes (AES-GCM standard)
- ciphertext: variable length
- tag: 16 bytes (GCM authentication tag, appended by Go's
aesGCM.Seal)
Symptom: Failed to decrypt payload on every real login attempt, but test
payloads without + characters work fine.
Root cause: URLSearchParams treats + as a space (per the
application/x-www-form-urlencoded spec). Base64-encoded payloads almost always
contain + characters. A 700+ character IDP payload has near-100% chance of
containing at least one +.
The fix (in playtv-vue-ui/src/views/login/IDPCallback.vue):
// WRONG — corrupts base64 payloads
for (const [k, v] of new URLSearchParams(hash.substring(1))) {
result[k] = v;
}
// CORRECT — preserves literal + characters
for (const part of hash.substring(1).split("&")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const k = decodeURIComponent(part.slice(0, eq));
const v = decodeURIComponent(part.slice(eq + 1));
result[k] = v;
}decodeURIComponent only decodes %XX sequences. It does NOT convert + to
space. This is the correct behavior for URL fragment parameters.
PR: playtv-vue-ui#1424
Symptom: Invalid IDP token error after successful decryption. Grafana logs
show error="audience mismatch".
Root cause: The IDP (parler-identity-service/internal/infrastructure/auth/jwt.go)
never sets the aud (audience) claim in JWT tokens. The RegisteredClaims only
includes: iss, sub, exp, iat, nbf, jti. No aud.
The Go backend (go-social-api/internal/authapi/idp_verify.go:214) checks:
if cfg.Audience != "" && claims.Audience != cfg.Audience {
return idpClaims{}, fmt.Errorf("audience mismatch")
}If IDP_AUDIENCE is set to anything, this check will ALWAYS fail because the
token's audience is always empty.
The fix: Do NOT set IDP_AUDIENCE, IDP_AUDIENCE_PLAYTV, or
IDP_AUDIENCE_PARLER in any GVC. Leave them empty/unset. The audience check is
skipped when cfg.Audience == "".
If someone later adds aud to IDP tokens, then and only then should these env
vars be set to the corresponding client_id values.
Symptom: Chrome Custom Tab closes immediately when IDP redirects to
play.parler.com/idp/callback. The app opens to its main screen instead of
processing the callback.
Root cause: play-phoenix-mobile/apps/play-mobile/app.config.js registers
App Links for play.parler.com with autoVerify: true and NO path restrictions.
Android intercepts ALL https://play.parler.com/* URLs — including the IDP
callback URL that must stay in the browser.
The fix: Add pathPrefix to intent filters so Android only intercepts paths
the app actually handles natively:
intentFilters: [
{
action: 'VIEW',
autoVerify: true,
data: [
{ scheme: 'https', host: 'play.parler.com', pathPrefix: '/v/' },
{ scheme: 'https', host: 'play.parler.com', pathPrefix: '/b/' },
{ scheme: 'https', host: 'play.parler.com', pathPrefix: '/c/' },
{ scheme: 'https', host: 'play.parler.com', pathPrefix: '/hashtag/' },
// ... same for all other hosts
],
category: ['BROWSABLE', 'DEFAULT'],
},
],The playtv:// custom scheme intent filter is SEPARATE and unchanged — that's
what receives the final deep link from IDPCallback.vue.
iOS is not affected. iOS path restrictions are controlled by the Apple App Site Association (AASA) file on the server, not the Expo config.
Symptom: POST /v3/playtv/authenticate returns 503.
Root cause: The playtv-production GVC (Laravel) is missing GO_API_ACTIVE
and GO_API_BASE_URL. The GoApiProxyController checks GO_API_ACTIVE and
returns 503 if it's not set.
// social-api/app/Http/Controllers/GoApiProxyController.php
if (!env('GO_API_ACTIVE')) {
return response()->json(['error' => 'Service unavailable'], 503);
}The fix: Set both env vars on the playtv-production GVC:
GO_API_ACTIVE=true
GO_API_BASE_URL=https://go-social-api-m351t11btasrr.da11-prod-1.controlplane.us
Then force-redeploy:
cpln workload force-redeployment api --org parler-cloud-technologies --gvc playtv-productionSymptom: Changed VITE_API_HOST in Vercel but the deployed JS bundle still
has the old value.
Root cause: Vite inlines import.meta.env.* values at build time. Vercel's
build cache may reuse a previous build if the source code hasn't changed, even if
env vars have.
The fix: When changing VITE_* env vars on Vercel:
- Update the var in Vercel project settings
- Trigger a redeploy with "Use build cache" UNCHECKED
- Verify by curling the deployed JS bundle and searching for the new value
-
Is the Vue app POSTing to the right API?
- Check
VITE_API_HOSTon Vercel — must match the environment - Curl the deployed JS bundle:
curl -s https://play.parler.com/assets/IDPCallback-*.js | grep -o 'https://[^"]*'
- Check
-
Is the Laravel proxy forwarding correctly?
- Check
GO_API_ACTIVEandGO_API_BASE_URLonplaytv-production GO_API_BASE_URLmust point to the SAME environment's Go backend
- Check
-
Does the client_secret match?
- Query IDP database:
SELECT client_secret FROM platforms WHERE name ILIKE '%play%' - Compare with
IDP_CLIENT_SECRET_PLAYTVon the Go backend's GVC - They must be identical — even one character off means decrypt fails
- Query IDP database:
-
Is the payload being corrupted in transit?
- Check for
+→ space conversion (URLSearchParams bug) - Use Chrome DevTools Network tab or HAR capture to see the actual POST body
- Check for
-
Check Grafana logs for the specific error:
- Go to Grafana → Explore → Loki
- Query:
{service_name="go-social-api"} |= "authenticate" or |= "IDP token" - Look for the
errorfield in WARN logs
-
Common causes:
audience mismatch→IDP_AUDIENCEis set but IDP tokens have noaudclaim. Remove it.token expired→ Clock skew or stale IDP sessionsignature verification failed→ JWKS URL is wrong or IDP rotated keys
The Go backend logs to Grafana via CPLN's built-in log shipping.
# Find all authenticate requests
{service_name="go-social-api"} |= "authenticate"
# Find IDP errors specifically
{service_name="go-social-api"} |= "IDP"
# If you don't see logs, remove all filters except the basic label matcher
# and search broadly. CPLN eventlogs (cpln workload eventlog) show container
# lifecycle events, NOT application logs.
| Environment | Vue App | API Proxy | Go Backend GVC | IDP |
|---|---|---|---|---|
| Development | playforte.ws (Vercel) | api.playforte.ws (playtv-development) | social-development | id.dev.parler.com |
| Production | play.parler.com (Vercel) | play-api.parler.com (playtv-production) | social-production | id.parler.com |
| Environment | Org |
|---|---|
| Development | parler-dev |
| Production | parler-cloud-technologies |
The Go backend deploys via tag-based GitHub Actions:
# Development
git tag deploy-go-backend-N-dev # triggers deploy to social-development
# Production
git tag deploy-go-backend-N-prod # triggers deploy to social-productionThe deploy does NOT touch playtv-production (Laravel) — that's a separate
workload/GVC. The Go backend runs in social-production and the Laravel proxy
in playtv-production forwards to it.
Deploys via Vercel auto-deploy on merge to the corresponding branch:
development branch → dev deployment
qa branch → QA deployment
stage branch → staging deployment
production branch → production deployment
To promote a fix through all environments, create PRs:
development → qa → stage → production
After IDP SSO is confirmed working:
- Re-enable reCAPTCHA for Play in IDP database
- Merge
CU-86e0nkcnh/idp-token-exchangebranch tomasterin go-social-api - Update go-social-api CI to also deploy to
playtv-productionGVC (long-term) - Consider adding
audclaim to IDP tokens and settingIDP_AUDIENCEproperly