diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index fe1f6fd..8269621 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -25,6 +25,7 @@ import { CachedLogin, ChangePasswordRequest, ChangePasswordResponse, + FakeCachedLogin, form, json, OAuthError, @@ -58,6 +59,18 @@ const PLATFORM_TYPES: Record = { 8: 'Pico', } +/** PlatformType for Oculus, which `/cachedlogin/forplatformid` currently stubs out. */ +const OCULUS_PLATFORM = 1 + +/** The canned entry served for any Oculus cached-login lookup. See the route below. */ +const FAKE_OCULUS_CACHED_LOGIN = { + platform: OCULUS_PLATFORM, + 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). @@ -254,7 +267,9 @@ const app = new Hono() 'Accounts the client may offer on its login screen for this platform identity. ' + 'Filtered to those a `cached_login` grant would actually accept, so an entry here ' + 'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls ' + - 'back to a fresh login or create_account.', + 'back to a fresh login or create_account. ' + + 'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one ' + + 'canned, non-redeemable entry with `requirePassword: true`.', parameters: [ { name: 'platform', @@ -271,12 +286,23 @@ const app = new Hono() schema: { type: 'string' }, }, ], - responses: { 200: json(CachedLogin.array(), 'Matching accounts; `[]` if none') }, + responses: { + 200: json( + CachedLogin.or(FakeCachedLogin).array(), + 'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).' + ), + }, }), async (c) => { const { platform, id } = c.req.param() logger.info('cached login lookup', { platform, id }) const platformInt = Number.parseInt(platform, 10) + // Oculus has no identity flow yet, so there is nothing in the DB to look up and + // the real path would always yield []. Hand back one canned entry instead, so the + // Oculus client gets past its login screen. `requirePassword` is true — unlike a + // genuine cached login there is no platform ticket behind this, so the client must + // prompt. Delete this branch once Oculus platform auth lands. + if (platformInt === OCULUS_PLATFORM) return c.json([FAKE_OCULUS_CACHED_LOGIN]) const accounts = await getAccountsByPlatformId(c.env.DB, id) // Offer only accounts the `cached_login` grant will actually accept — same check. return c.json( diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index 35af84b..c86399d 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -70,6 +70,14 @@ export const CachedLogin = z.object({ .describe('Always false — platform ownership is the credential for a cached login'), }) +/** + * The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword` + * is true — nothing proves platform ownership, so the client has to prompt. + */ +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 18bf6c6..be584e7 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -117,12 +117,28 @@ describe('auth worker routes', () => { expect(await res.text()).toBe('"AA=="') }) + // Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below. test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => { - const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`) + const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) + // Oculus is stubbed: no DB lookup, one canned entry whatever the id. + test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => { + const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`) + 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 (platform 0) can be verified (via its signed platform_auth ticket), // so every OTHER platform is rejected on the platform-authenticated grants — we // won't bind or authorize an identity we can't prove. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4880c5..2e6c234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,7 +688,7 @@ importers: specifier: workspace:* version: link:../../packages/hono-helpers '@scalar/api-reference': - specifier: ^1.63.0 + specifier: 1.63.0 version: 1.63.0(tailwindcss@4.3.3)(typescript@6.0.3)(zod@4.4.3) hono: specifier: 4.12.27 @@ -4440,7 +4440,7 @@ snapshots: '@floating-ui/vue@1.1.9(vue@3.5.40(typescript@6.0.3))': dependencies: '@floating-ui/dom': 1.8.0 - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.12 vue-demi: 0.14.10(vue@3.5.40(typescript@6.0.3)) transitivePeerDependencies: - '@vue/composition-api'