diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index f7b4efb..2043410 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -29,6 +29,7 @@ import { CachedLogin, ChangePasswordRequest, ChangePasswordResponse, + FakeCachedLogin, form, json, OAuthError, @@ -57,6 +58,19 @@ import type { PlatformLink } from './platform-db' const TOKEN_SCOPE = 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' +/** + * The canned entry served for the one Oculus cached-login lookup below — the sideloaded + * APK's way onto the password login screen. Not backed by a link, an account or a + * platform proof, hence `requirePassword: true`. + */ +const FAKE_OCULUS_CACHED_LOGIN = { + platform: PlatformType.Oculus, + platformId: '1', + accountId: 1, + lastLoginTime: '2026-07-19T17:13:29.225Z', + requirePassword: true, +} as const + /** * Signup caps, enforced on create_account only (never on login — an existing account * always stays reachable, however many accounts its owner has since accumulated). @@ -361,6 +375,10 @@ const app = new Hono() '`cached_login` grant (both read the same table). An account linked to several', 'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)', 'and the client falls back to a fresh login or create_account.', + 'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED', + 'APKs: with no Meta SDK they have no real identity to ask about and stall on an', + 'empty picker. It consults nothing and returns one canned, non-redeemable entry', + 'with `requirePassword: true`, sending the build to username/password login.', ].join(' '), parameters: [ { @@ -379,13 +397,30 @@ const app = new Hono() }, ], responses: { - 200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'), + 200: json( + CachedLogin.or(FakeCachedLogin).array(), + 'Matching accounts; `[]` if none. The canned entry for `1/1`.' + ), }, }), async (c) => { const { platform, id } = c.req.param() logger.info('cached login lookup', { platform, id }) const platformInt = Number.parseInt(platform, 10) + // SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't + // produce a real Meta identity or a nonce to prove one with — it asks about the + // placeholder identity `1/1`, and an empty picker leaves it stuck on the platform + // login screen with nothing to do. Hand back one canned entry to push it onto the + // username/password login instead, which is the only flow such a build can finish. + // `requirePassword` is true for exactly that reason: there's no platform proof here, + // and the `cached_login` grant would (correctly) refuse this entry. + // + // Scoped to that ONE identity rather than to all of platform 1 — store builds do + // real Meta logins, and shadowing the whole platform would hide genuine links from + // their pickers. + if (platformInt === PlatformType.Oculus && id === '1') { + return c.json([FAKE_OCULUS_CACHED_LOGIN]) + } // Listed straight from the link table, which is also what the `cached_login` // grant authorizes against — so the picker can't offer an account the grant // then refuses. diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index 79436de..316fdf0 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -103,6 +103,15 @@ export const CachedLogin = z.object({ .describe('Always false — platform ownership is the credential for a cached login'), }) +/** + * The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`, + * but `requirePassword` is true — with no Meta SDK there is nothing to prove platform + * ownership with, so the client falls through to username/password. + */ +export const FakeCachedLogin = CachedLogin.extend({ + requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'), +}) + /** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */ export const OAuthError = z.object({ error: z.enum(['invalid_grant', 'invalid_request', 'server_error']), diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index a6e5658..27db45a 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -198,6 +198,23 @@ describe('auth worker routes', () => { } ) + // The one stubbed identity: `1/1` consults nothing and always answers the canned + // entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off + // the platform login screen and onto username/password. + test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => { + const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { + platform: 1, + platformId: '1', + accountId: 1, + lastLoginTime: '2026-07-19T17:13:29.225Z', + requirePassword: true, + }, + ]) + }) + // Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth // ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected // on the platform-authenticated grants: we won't bind or authorize an identity we