SESSION CHECK PATH (existing IDP session)
=========================================
MOBILE APP IDP SERVICE WEB CALLBACK BRIDGE GO BACKEND
(play-phoenix-mobile) (parler-identity-service) (playtv-vue-ui / (go-social-api)
social-vue-ui)
+---------------------------+
| User taps |
| "Sign in with Parler ID" |
+------------+--------------+
|
| WebBrowser.openAuthSessionAsync()
| Opens system browser (CCT / SFSafariVC)
v
| GET /session/check?client_id={id}&platform=mobile
+------------------------------------------------------------>+---------------------------+
| Read idp_token cookie |
| (HttpOnly, Secure) |
| Contains IDP JWT |
+------------+--------------+
|
v
+---------------------------+
| Verify IDP JWT (RSA-256) |
| Claims: ulk, identifier, |
| session_id, exp, iat |
+------------+--------------+
|
+-----------+-----------+
| Session valid? |
+-----------+-----------+
YES | | NO
v v
+-------------+ Redirect with
| Show account| #action=unauthorized
| picker | (goes to OTP path)
+------+------+
|
| User selects account
| POST /api/v1/session/continue
v
+---------------------------+
| Encrypt tokens |
| AES-256-GCM |
| Key: SHA256(client_secret)|
| Nonce: 12 random bytes |
+------------+--------------+
|
| HTTP redirect to platform redirect_url
| Fragment: #action=ok&payload={base64(nonce+ciphertext+tag)}
v
+---------------------------+
| IDPCallback.vue |
| Parse URL fragment |
| Extract encrypted payload |
+------------+--------------+
|
| POST /v3/{app}/authenticate
| Body: { "payload": "<base64>" }
|
| (via Laravel proxy if Play)
v
+---------------------------+
| Decrypt AES-256-GCM |
| Extract IDP JWT |
+------------+--------------+
|
v
+---------------------------+
| Verify IDP JWT (RSA-256) |
| Fetch JWKS from IDP |
| Validate: iss, exp, nbf |
| (NO aud check) |
+------------+--------------+
|
v
+---------------------------+
| Lookup user by ULK |
| or bootstrap by |
| email/phone |
+------------+--------------+
|
v
+---------------------------+
| Mint Passport tokens |
| JWT (HS256) |
| + refresh token |
+------------+--------------+
|
+-------------------------------------------+
| Response 200: { access_token, refresh_token }
v
+---------------------------+
| Build deep link: |
| {scheme}://auth/callback |
| ?access_token={PASSPORT} |
| &refresh_token={REFRESH} |
| &auth_type=idp_session |
+------------+--------------+
|
+<------------------------------------------------------------- window.location.href = deepLink
|
v
+---------------------------+
| Parse callback params |
| auth_type = idp_session |
| Tokens are Passport JWTs |
| Store directly |
+------------+--------------+
|
v
+---------------------------+
| User is logged in |
| AsyncStorage persists |
| tokens for API calls |
+---------------------------+
OTP LOGIN PATH (no existing IDP session)
========================================
MOBILE APP IDP SERVICE WEB CALLBACK BRIDGE GO BACKEND
+---------------------------+
| WebBrowser still open |
| IDP returned |
| #action=unauthorized |
+------------+--------------+
|
v
+---------------------------+
| IDPCallback.vue detects |
| action=unauthorized |
| Shows IDP login form |
+------------+--------------+
|
| User enters email/phone
v
+---------------------------+
| IDP sends OTP code |
| User enters code |
| IDP verifies OTP |
+------------+--------------+
|
| Sets idp_token cookie
| Redirects with IDP JWT (not encrypted)
v
+---------------------------+
| Deep link to app: |
| {scheme}://auth/callback |
| ?access_token={IDP_JWT} |
| &auth_type=idp_login |
+------------+--------------+
|
+<------------------------------------------------------------------------|
|
v
+---------------------------+
| Parse callback params |
| auth_type = idp_login |
| Token is IDP JWT |
| (NOT Passport yet) |
+------------+--------------+
|
| POST /v3/{app}/login/idp
| Body: { "token": "<IDP JWT>" }
+----------------------------------------------------------------------------------->+---------------------------+
| Verify IDP JWT (RSA-256) |
| Lookup/bootstrap user |
| Mint Passport tokens |
+------------+--------------+
|
+<-----------------------------------------------------------------------------------------------|
| Response 200: { access_token, refresh_token }
v
+---------------------------+
| Store Passport tokens |
| User is logged in |
+---------------------------+
SOCIAL LOGIN PATH (Google / Apple via IDP)
==========================================
WEB BROWSER IDP SERVICE WEB LOGIN PAGE GO BACKEND
+---------------------------+
| User on login page |
| (web or mobile bridge) |
+------------+--------------+
|
| Clicks "Sign in with Google" or "Sign in with Apple"
v
+---------------------------+
| Google: GIS SDK popup |
| returns credential |
| (id_token JWT) |
| Apple: AppleID.auth popup |
| returns id_token |
+------------+--------------+
|
| POST {IDP_BASE_URL}/api/v1/auth/social/login
| Body: { client_id, provider: "google"|"apple", id_token }
| credentials: "include" (sends IDP cookies)
+------------------------------------------------------------>+---------------------------+
| Verify provider id_token |
| Google: JWKS validation |
| Apple: JWKS validation |
| Check aud matches |
| platform google_client_id|
| or apple_client_id |
+------------+--------------+
|
+------------+--------------+
| Lookup by provider_sub |
| in external_providers |
+------------+--------------+
|
+-----------+-----------+
| User exists? |
+-----------+-----------+
YES | | NO
v v
+------------+ +------------------+
| Login | | AUTO-CREATE: |
| existing | | - New identity |
| identity | | - linked_identity|
+------------+ | - external_prov |
| - profile |
+------------------+
|
+------------+--------------+
| Return IDP JWT |
| { access_token, |
| refresh_token, |
| expires_in } |
+------------+--------------+
|
+<------------------------------------------------------------|
|
v
+---------------------------+ +---------------------------+
| WEB FLOW: | | MOBILE BRIDGE FLOW: |
| POST /v3/{app}/login/idp | | Build deep link: |
| { token: IDP_JWT } | | {scheme}://auth/callback |
| Go backend verifies, | | ?access_token={IDP_JWT} |
| mints Passport tokens | | &auth_type=idp_login |
| fetchUserState() | | window.location.href = |
| User logged in on web | | deepLink |
+---------------------------+ +---------------------------+
|
v
Mobile app exchanges via
POST /v3/{app}/login/idp
(same as OTP path)
LOGOUT PATH (from IDP account picker)
======================================
MOBILE BROWSER IDP SERVICE WEB LOGIN PAGE
+---------------------------+
| User on IDP account |
| picker page |
| (id.parler.com or |
| id.dev.parler.com) |
+------------+--------------+
|
| User taps "Log out"
v
| POST /api/v1/auth/logout
| Body: { client_id: "..." }
+------------------------------------------------------------>+---------------------------+
| Revoke sessions on device |
| Clear idp_token cookie |
| Clear idp_device_id cookie|
+------------+--------------+
|
| Lookup platform_url from
| platform_settings table
| (key = "platform_url")
v
+---------------------------+
| Return JSON: |
| { redirect: true, |
| redirect_url: |
| "<platform_url>" } |
+------------+--------------+
|
+<------------------------------------------------------------|
| IDP frontend appends ?logout=true
| window.location.href = redirect_url
v
+---------------------------+
| Web login page renders |
| (mobile IDP login form) |
| User can sign in again |
+---------------------------+
NOTE: platform_url must include the full mobile login path with query params:
Play: https://play.parler.com/login?platform=mobile&redirect_url=playtv%3A%2F%2Fauth%2Fcallback
Parler: https://app.parler.com/idp/login?platform=mobile&redirect_url=parlersocial%3A%2F%2Fauth%2Fcallback
platform_url = where to go AFTER LOGOUT (home base)
redirect_url = where to go AFTER LOGIN (token callback) — do NOT use for logout
- User taps "Sign in with Parler ID" (or Play equivalent) on the login screen
- App calls
WebBrowser.openAuthSessionAsync()which opens a system browser- Android: Chrome Custom Tab
- iOS: SFSafariViewController
- URL opened:
{IDP_BASE_URL}/session/check?client_id={IDP_CLIENT_ID}&platform=mobile - App begins listening for a deep link callback at
playtv://auth/callbackorparlersocial://auth/callback
- IDP reads the
idp_tokencookie (HttpOnly, Secure, SameSite=Strict)- This cookie contains an IDP JWT signed with RSA-256
- Claims include:
ulk(user ID),identifier(email/phone),session_id,exp,iat,nbf,jti
- IDP also reads
idp_device_idcookie for trusted device tracking - If no cookie or expired session: returns
redirect_urlwith#action=unauthorized(skips to OTP path) - If valid session: proceeds to account picker
- IDP returns JSON with linked accounts for this platform:
linked_accounts[]— each hasid,identifier,name,ulkdefault_identifier,default_ulk,identifier_type
- The
platform=mobileparam forces the account picker to show even for single accounts - User selects an account
- Frontend calls
POST /api/v1/session/continuewith the selectedidentifierandclient_id - IDP generates a verify token (JWT with
subset to the selected account's ULK)
- IDP encrypts the token payload using AES-256-GCM:
- Key derivation: if
client_secretis exactly 32 bytes, use raw; otherwise SHA-256 hash it to 32 bytes - Nonce: 12 random bytes (standard GCM)
- Plaintext: JSON
{ access_token, refresh_token, expires_in, refresh_expires_in } - Output:
base64(nonce + ciphertext + GCM_tag)
- Key derivation: if
- IDP redirects browser to the platform's
redirect_urlfrom the database:- Play:
https://play.parler.com/idp/callback(prod) orhttps://playforte.ws/idp/callback(dev) - Parler Social:
https://app.parler.com/idp/callback(prod) orhttps://app.forte.ws/idp/callback(dev)
- Play:
- URL fragment:
#action=ok&payload={encrypted_base64} - IMPORTANT: the
+characters in base64 must NOT be corrupted by URLSearchParams parsing
IDPCallback.vue(on Vercel) parses the URL fragment (#) parameters- Extracts
actionandpayloadfrom the fragment - For
action=okwith apayload:- Sends
POST /v3/{app}/authenticateto the API backend - Request body:
{ "payload": "<base64_encrypted_string>" } - For Play: goes through Laravel proxy (
play-api.parler.com-> Go backend) - For Parler Social: goes through Laravel proxy (
api.forte.ws-> Go backend) or direct
- Sends
IDPAuthenticateHandlerreceives the encrypted payload- Decryption steps:
- Base64 decode (tries standard encoding, falls back to URL encoding)
- Split: first 12 bytes = nonce, rest = ciphertext+tag
- Derive key from
IDP_CLIENT_SECRET_{APP}(same SHA-256 logic as IDP) aes.NewCipher(key)->cipher.NewGCM(block)->gcm.Open()
- Extracts decrypted JSON:
{ access_token, refresh_token, expires_in, refresh_expires_in } - The
access_tokenhere is an IDP JWT (not yet a Passport token)
- Decodes JWT header to get
kid(key ID) - Fetches IDP's JWKS from
IDP_JWKS_URL(e.g.,https://id.parler.com/.well-known/jwks.json)- JWKS is cached per-app with 1-hour TTL
- Verifies RSA-256 signature using the matching public key
- Validates claims:
issmust matchIDP_ISSUERconfigexpmust be in the futurenbfmust be satisfiedaudcheck is SKIPPED (IDP tokens have noaudclaim — do NOT setIDP_AUDIENCE)
- Extracts
ulkfrom JWT claims (subfor session-check tokens,ulkfor OTP tokens) - Lookup order:
FindUserByExternalIdentity("idp", ulk)— exact ULK match- If not found, bootstrap by email or phone from JWT claims
- If bootstrapping:
CreateExternalIdentityLink(user_id, "idp", ulk)to link for future logins
- If no user found by any method: returns 422 "No account found for this {field}. Please sign up first."
- This is the create-account gap — IDP creates identities but Go API requires a pre-existing local user
- See "Account Creation Gap" section below
- Calls
issueTokenForUser(user, "idp") - Passport JWT (HS256):
- Claims:
sub(user ID),ulk,email,iat,exp,jti - Expiry: 3600 seconds (1 hour)
- Claims:
- Refresh token: expiry 43200 seconds (12 hours)
- Response:
{ access_token, refresh_token, expires_in, refresh_expires_in }
IDPCallback.vuereceives the Passport tokens from the backend response- Builds a deep link URL:
playtv://auth/callback?access_token={PASSPORT}&refresh_token={REFRESH}&expires_in=3600&auth_type=idp_session- or
parlersocial://auth/callback?...for Parler Social
- Sets
window.location.hrefto the deep link - System browser closes, app receives the URL
- App parses callback URL query parameters
- Checks
auth_type:idp_session: tokens are already Passport tokens — store directlyidp_login: token is IDP JWT — needs exchange first (see OTP path below)
- Stores tokens via
setTokens()in AsyncStorage - Fetches user profile:
GET /userwithAuthorization: Bearer {passport_token} - User is logged in
When the user has no idp_token cookie (first login or expired session):
- IDP returns
#action=unauthorizedin the redirect - Web bridge shows the IDP login form in the browser
- User enters email or phone number
- IDP sends a one-time password (OTP) via email/SMS
- User enters OTP code
- IDP verifies OTP, creates a session, sets
idp_tokencookie - IDP redirects with the IDP JWT (may or may not be encrypted depending on platform config)
- Deep link back to app:
{scheme}://auth/callback?access_token={IDP_JWT}&auth_type=idp_login - Mobile app detects
auth_type=idp_login - Mobile app calls
POST /v3/{app}/login/idpwith{ "token": "<IDP_JWT>" } - Go backend verifies JWT, looks up user, mints Passport tokens (same as steps 7-9)
- Mobile stores Passport tokens, user is logged in
IDP auto-creates identities: If the email/phone is new to IDP, StartOTP automatically creates an identity and linked_identity before sending the OTP code (otp_service.go:370-374). No separate registration step is needed on the IDP side.
The web login pages (playtv-vue-ui, social-vue-ui) handle Google and Apple sign-in via the IDP social login endpoint. This works for both direct web login and the mobile bridge flow.
- User clicks "Sign in with Google" (GIS SDK
renderButton) or "Sign in with Apple" (AppleID.auth popup) - SDK returns an
id_token(JWT credential) - Frontend calls
POST {IDP_BASE_URL}/api/v1/auth/social/loginwith{ client_id, provider, id_token } - IDP verifies the id_token against provider JWKS, checks
audmatches platform'sgoogle_client_idorapple_client_id - IDP auto-creates identity if new (creates identity + linked_identity + external_provider + profile)
- Returns IDP JWT
{ access_token, refresh_token, expires_in } - Frontend exchanges via
POST /v3/{app}/login/idp { token: IDP_JWT }→ Go backend mints Passport tokens fetchUserState()→ user logged in
Same steps 1-6, but instead of exchanging tokens on web:
7. Frontend builds deep link: {scheme}://auth/callback?access_token={IDP_JWT}&auth_type=idp_login
8. window.location.href = deepLink
9. Mobile app exchanges via POST /v3/{app}/login/idp → Passport tokens → logged in
google_client_idin IDP platform_settings must matchVITE_GOOGLE_CLIENT_IDin the web app buildapple_client_idin IDP platform_settings must matchVITE_APPLE_CLIENT_IDin the web app build- Apple return URLs must be registered on the Apple Services ID for every domain serving the login page
- Google JS origins must be registered in GCP for every domain serving the login page
- IDP CORS must allow every domain that calls the social login endpoint
When the user is on the IDP page (e.g., id.parler.com) and taps logout:
- IDP frontend calls
POST /api/v1/auth/logoutwith{ client_id }(session_check_handler.go:695) - IDP backend revokes all sessions on the device, clears
idp_tokenandidp_device_idcookies - Looks up
platform_urlfromplatform_settingstable (key =platform_url) (session_check_handler.go:740) - Returns
{ redirect: true, redirect_url: "<platform_url>" } - IDP frontend appends
?logout=trueand redirects:window.location.href = redirect_url
IMPORTANT: platform_url must include the full mobile login path with query parameters so the user lands on the mobile IDP login page, not the web feed:
- Play:
https://play.parler.com/login?platform=mobile&redirect_url=playtv%3A%2F%2Fauth%2Fcallback - Parler:
https://app.parler.com/idp/login?platform=mobile&redirect_url=parlersocial%3A%2F%2Fauth%2Fcallback
Key distinction:
platform_url= where to go after logout (home base for the platform)redirect_url= where to go after login (callback URL for session check) — do NOT change this for logout fixes
Native mobile app logout is completely separate — the app clears tokens, resets state, and the navigator shows the native LoginScreen. No browser involved, no web redirect.
IDP and Go API handle new users differently:
| Component | New user via Google/Apple | New user via email/phone OTP |
|---|---|---|
| IDP | Auto-creates identity + linked_identity + external_provider | Auto-creates identity + linked_identity, sends OTP |
| Go API | Requires existing local user → 422 if not found | Requires existing local user → 422 if not found |
The gap: IDP happily creates identities for new users, but Go API's IDPVerifyHandler (idp_verify.go:404) returns "No account found for this {field}. Please sign up first." when there's no matching local user in the social-api database.
Options:
- Auto-create in Go API — Add a branch at
idp_verify.go:404to create a local user from IDP claims. Challenges: username generation, ToS acceptance, phone wireless check. - Client-side signup flow — Frontend catches the 422, redirects to a signup form pre-filled with IDP claims, user picks a username, normal
CreateUserflow, then re-attempts IDP login. Preserves all signup gates.
IDP also has a POST /auth/register endpoint, but it requires client_secret (server-to-server only) and is designed for password-based registration, not the OTP-only flow.
The web apps serving the login pages may have Apple App Site Association (AASA) files with wildcard rules that intercept URLs. If /login or /idp/callback is not excluded, iOS will try to open the URL in the native app instead of rendering the web page in the browser. This breaks the IDP flow.
Required AASA exclusions:
{
"paths": [
"NOT /idp/callback",
"NOT /login",
"*"
]
}Without these, iOS intercepts the logout redirect URL and the OTP callback, preventing the browser from rendering the login page.
The OTP verify endpoint (POST /api/v1/auth/otp/verify) consumes the challenge on the first successful call. If the frontend fires the request twice (double-tap, slow network retry), the second call returns 422 "challenge not found or expired."
Mitigation: Disable the verify/submit button after the first tap. The IDPLogin.vue components should set loading=true and disabled on the button immediately when clicked.
| Token | Format | Signed With | Where It Lives | Purpose |
|---|---|---|---|---|
idp_token cookie |
JWT | RSA-256 (IDP private key) | Browser cookie (HttpOnly, Secure) | IDP session — proves user authenticated with IDP |
| IDP verify token | JWT | RSA-256 (IDP private key) | Encrypted in redirect payload | Short-lived token for a specific account selection |
| Encrypted payload | AES-256-GCM blob | Platform client_secret |
URL fragment in redirect | Wraps IDP JWT for secure transit through browser |
| Passport access token | JWT | HS256 (Go backend secret) | Mobile app AsyncStorage | App session — used for all API calls |
| Passport refresh token | opaque string | N/A | Mobile app AsyncStorage | Used to refresh expired Passport access token |
| Google id_token | JWT | RSA-256 (Google) | Transient (GIS SDK callback) | Proves Google authentication, exchanged via IDP |
| Apple id_token | JWT | RSA-256 (Apple) | Transient (AppleID.auth callback) | Proves Apple authentication, exchanged via IDP |
| Component | Variable | Purpose |
|---|---|---|
| Go Backend | IDP_CLIENT_SECRET_{APP} |
Must match IDP platform's client_secret exactly |
| Go Backend | IDP_JWKS_URL |
Where to fetch IDP public keys for JWT verification |
| Go Backend | IDP_ISSUER |
Expected iss claim in IDP JWTs |
| Go Backend | IDP_LOGIN_ENABLED_{APP} |
Must be true to enable IDP login |
| Go Backend | IDP_AUDIENCE_{APP} |
Must be UNSET — IDP tokens have no aud claim |
| Laravel | GO_API_ACTIVE |
Must be true or all /v3/* routes return 503 |
| Laravel | GO_API_BASE_URL |
Must point to correct environment's Go backend |
| Web Bridge | VITE_API_HOST |
API endpoint (baked at Vite build time!) |
| Web Bridge | VITE_IDP_BASE_URL |
IDP URL (baked at build time) |
| Web Bridge | VITE_IDP_CLIENT_ID |
Platform client_id (baked at build time) |
| Web Bridge | VITE_GOOGLE_CLIENT_ID |
Must match IDP platform google_client_id |
| Web Bridge | VITE_APPLE_CLIENT_ID |
Must match IDP platform apple_client_id |
| Mobile | EXPO_PUBLIC_IDP_BASE_URL |
IDP URL for session check |
| Mobile | EXPO_PUBLIC_IDP_CLIENT_ID |
Platform client_id registered in IDP |
| Mobile | EXPO_PUBLIC_PLAY_WEB_URL |
Web app URL for OTP fallback (defaults to prod if unset!) |
| Mobile | EXPO_PUBLIC_PARLER_WEB_URL |
Parler web app URL for OTP fallback (defaults to prod if unset!) |
| IDP DB | platforms.client_secret |
Must match Go backend's IDP_CLIENT_SECRET_{APP} |
| IDP DB | platform_settings.redirect_url |
Must point to exact /idp/callback route on web bridge |
| IDP DB | platform_settings.platform_url |
Logout redirect — must include full mobile login path with query params |
| IDP DB | platform_settings.google_client_id |
Must match VITE_GOOGLE_CLIENT_ID in web app build |
| IDP DB | platform_settings.apple_client_id |
Must match VITE_APPLE_CLIENT_ID in web app build |
Every domain that serves a login page calling IDP endpoints must be in IDP's CORS_ALLOWED_ORIGINS:
Dev IDP (id.dev.parler.com):
https://app.forte.ws,https://forte.ws(social-vue-ui)https://playforte.ws,https://app.playforte.ws,https://play.forte.ws(playtv-vue-ui)https://play.dev.parler.com(play alias)http://localhost:*(local dev)
Prod IDP (id.parler.com):
https://app.parler.com(social-vue-ui)https://play.parler.com,https://playtv.parler.com(playtv-vue-ui)- Other platform domains (pay, shop, ads)
CORS is configured via CORS_ALLOWED_ORIGINS env var on the IDP workload. Changes via CPLN UI can silently modify other workload config — always commit to repo and deploy via CI.