Diagnosis: auth setup is failing on staging
While debugging the "Failed to connect account" error a tester saw on staging, I tried the token exchange directly with curl against Airship's /token endpoint using a test App Key + Secret. Found two layers of issues — one with the credentials provided, one with our config + framework.
Layer 1 — the test credentials don't authenticate against Airship at all
Test creds (App Key uUyc19voSRqP9sdix4H7cQ / Secret ZjOHsLxFSWu6mlYMgCodag) return HTTP 401 invalid_client against both:
- The OAuth 2.0 token endpoint (
oauth2.asnapius.com/token and oauth2.asnapieu.com/token), with the correct request shape — see Layer 2 below.
- Airship's legacy data plane (
go.urbanairship.com/api/channels, api.asnapius.com/api/channels), which would have worked if they were the classic App Key + Master Secret pair.
Most likely: the test creds are the project's App Key + Master Secret, not the OAuth Client's separately-issued client_id + client_secret. In Airship's console, the OAuth Client (Settings → APIs & Integrations → OAuth) is a different artifact from the project App Key, and gets its own UUID-shaped client_id + secret.
This is a tester-side fix (get valid OAuth Client credentials), not a config-side fix.
Layer 2 — Airship's /token deviates from standard OAuth 2.0 in ways this PR doesn't handle
While testing, I confirmed (against Airship's OAuth docs) that the token endpoint diverges from the standard S2S_OAUTH2 shape in three ways:
Accept: application/json is required. Without it the endpoint returns 406. The framework's S2S code path doesn't appear to set an Accept header on the token request, so this currently fails on the wire.
- A
sub=app:<APP_KEY> body field is required. Non-standard — the OAuth 2.0 spec has no sub in the token request body. This PR doesn't send it.
- Scopes use repeated
scope= parameters, not space-separated: scope=psh&scope=chn&scope=evt rather than scope=psh chn evt. This PR's scope_separator: ' ' produces the wrong serialization.
The third point is the hardest: S2SOAuth2AuthSchemeSchema.token_config.params is typed as Record<string, string> in @composiohq/auth-config. You can't express a repeated form-encoded parameter in that shape. So even with the right credentials and the right Accept/sub/separator config, the framework can't currently serialize Airship's expected request body.
Implication
The PLEN-2215 audit's classification of airship as S2S_OAUTH2 was directionally right — it's client_credentials, no user redirect, admin-managed credentials. But Airship's actual OAuth 2.0 implementation diverges from the spec in ways our framework can't handle today. This puts airship closer to the Wix situation (custom-flow OAuth that needs OAUTH2-mode + a custom token shape) than to the clean Vanta/PayPal S2S pattern.
Recommended next steps (in order)
- Defer this PR. The migration as written cannot produce a working token exchange even with valid OAuth Client credentials. Don't merge until Layer 2 is resolved.
- Open a framework ticket on
@composiohq/auth-config to extend S2SOAuth2AuthSchemeSchema.token_config with: (a) params: Record<string, string | string[]> (so repeated scope= params can be expressed), (b) an optional accept_header / extra_headers override on token_config, (c) optional arbitrary extra body fields (for sub=app:<APP_KEY>). With those, airship becomes expressible.
- Alternative: keep airship on its existing
OAUTH2 mode misclassification (which has been on master for months and was already broken per the audit) and treat it like Wix — leave the file as-is and add a // ci-skip: config-breaking-changes / allowlist entry when the proposed Tier-1 lint lands.
Open to either approach. The audit's correctness call about airship doesn't change — but the implementation path needs framework support that doesn't yet exist.
🤖 Diagnostic posted as part of the PLEN-2215 staging validation.