Skip to content

Instantly share code, notes, and snippets.

@R4wm
Last active April 16, 2026 23:42
Show Gist options
  • Select an option

  • Save R4wm/eda1011ed57315f687e008f4b460759a to your computer and use it in GitHub Desktop.

Select an option

Save R4wm/eda1011ed57315f687e008f4b460759a to your computer and use it in GitHub Desktop.
IDP SSO Runbook — architecture, gotchas, debugging, and deployment guide

Play IDP SSO — Complete Runbook

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.


Architecture Overview

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

Key Repos

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

Key Endpoints

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)

Infrastructure: CPLN Environment Variables

playtv-production GVC (Laravel proxy)

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.

social-production GVC (Go backend)

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/* → uses IDP_*_PLAYTV vars, falls back to IDP_*
  • /v3/parler/* → uses IDP_*_PARLER vars, falls back to IDP_*

See go-social-api/internal/authapi/social_config.go for the full resolution logic.

Vercel (playtv-vue-ui)

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.


Encryption: AES-256-GCM

IDP encrypts the token payload before sending it in the redirect URL. The Go backend decrypts it using the same client_secret.

Key Derivation

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%';

Payload Format

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)

Gotcha #1: URLSearchParams Destroys Base64 Payloads

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


Gotcha #2: IDP Tokens Have No aud Claim

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.


Gotcha #3: Android App Links Intercept Chrome Custom Tab

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.


Gotcha #4: Laravel Proxy Env Vars

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-production

Gotcha #5: Vercel Build Cache Ignores Env Var Changes

Symptom: 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:

  1. Update the var in Vercel project settings
  2. Trigger a redeploy with "Use build cache" UNCHECKED
  3. Verify by curling the deployed JS bundle and searching for the new value

Debugging Playbook

"Failed to decrypt payload"

  1. Is the Vue app POSTing to the right API?

    • Check VITE_API_HOST on Vercel — must match the environment
    • Curl the deployed JS bundle: curl -s https://play.parler.com/assets/IDPCallback-*.js | grep -o 'https://[^"]*'
  2. Is the Laravel proxy forwarding correctly?

    • Check GO_API_ACTIVE and GO_API_BASE_URL on playtv-production
    • GO_API_BASE_URL must point to the SAME environment's Go backend
  3. Does the client_secret match?

    • Query IDP database: SELECT client_secret FROM platforms WHERE name ILIKE '%play%'
    • Compare with IDP_CLIENT_SECRET_PLAYTV on the Go backend's GVC
    • They must be identical — even one character off means decrypt fails
  4. 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

"Invalid IDP token"

  1. 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 error field in WARN logs
  2. Common causes:

    • audience mismatchIDP_AUDIENCE is set but IDP tokens have no aud claim. Remove it.
    • token expired → Clock skew or stale IDP session
    • signature verification failed → JWKS URL is wrong or IDP rotated keys

Grafana Log Queries

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 Matrix

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

CPLN Orgs

Environment Org
Development parler-dev
Production parler-cloud-technologies

Go Backend Deployment

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-production

The 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.


Vue App (playtv-vue-ui) Deployment

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


Post-Incident Checklist

After IDP SSO is confirmed working:

  • Re-enable reCAPTCHA for Play in IDP database
  • Merge CU-86e0nkcnh/idp-token-exchange branch to master in go-social-api
  • Update go-social-api CI to also deploy to playtv-production GVC (long-term)
  • Consider adding aud claim to IDP tokens and setting IDP_AUDIENCE properly
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment