diff --git a/src/config.ts b/src/config.ts index 7ba4815..099c503 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,7 +24,6 @@ export const config = { meta: { appId: required('META_APP_ID'), appSecret: required('META_APP_SECRET'), - redirectUri: required('META_REDIRECT_URI'), }, youtube: { diff --git a/src/routes/auth/meta.ts b/src/routes/auth/meta.ts index b966aa8..230ac59 100644 --- a/src/routes/auth/meta.ts +++ b/src/routes/auth/meta.ts @@ -1,6 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; import { randomUUID } from 'node:crypto'; -import { exchangeMetaCode, fetchMetaProfile } from '../../services/meta-auth.service.js'; +import { verifyUserProof, fetchOculusProfile } from '../../services/meta-auth.service.js'; import { signAccessToken } from '../../plugins/auth.js'; import { hashToken } from '../../services/crypto.service.js'; import { config } from '../../config.js'; @@ -15,37 +15,52 @@ const metaAuthRoutes: FastifyPluginAsync = async (fastify) => { schema: { body: { type: 'object', - required: ['code'], + required: ['userId'], properties: { - code: { type: 'string', minLength: 1 }, + userId: { type: 'string', minLength: 1 }, + nonce: { type: 'string' }, deviceInfo: { type: 'string' }, }, additionalProperties: false, }, }, }, async (request, reply) => { - const { code, deviceInfo } = request.body; + const { userId, nonce, deviceInfo } = request.body; + request.log.info({ userId, hasNonce: !!nonce, deviceInfo }, 'Meta callback received'); - // Exchange code for Meta access token - const { accessToken: metaToken } = await exchangeMetaCode(code); + // Nonce validation requires a numeric user ID from Meta's graph API. + // When DUC hasn't granted numeric ID access, the SDK returns oculusId (a string username). + const isNumericId = /^\d+$/.test(userId); - // Fetch user profile - const profile = await fetchMetaProfile(metaToken); + if (nonce && isNumericId) { + const isValid = await verifyUserProof(userId, nonce); + if (!isValid) { + throw new AppError(401, 'Invalid user proof'); + } + request.log.info('Nonce verified successfully'); + } else { + request.log.warn({ isNumericId, hasNonce: !!nonce }, + 'Skipping nonce verification (non-numeric userId or missing nonce)'); + } + + // Try to fetch profile from Oculus graph; fall back to userId as display name + let metaId = userId; + let displayName = userId; + if (isNumericId) { + try { + const profile = await fetchOculusProfile(userId); + metaId = profile.metaId; + displayName = profile.displayName; + } catch (e) { + request.log.warn(e, 'Failed to fetch Oculus profile'); + } + } // Upsert user const user = await fastify.prisma.user.upsert({ - where: { metaId: profile.metaId }, - update: { - displayName: profile.displayName, - email: profile.email, - avatarUrl: profile.avatarUrl, - }, - create: { - metaId: profile.metaId, - displayName: profile.displayName, - email: profile.email, - avatarUrl: profile.avatarUrl, - }, + where: { metaId }, + update: { displayName }, + create: { metaId, displayName }, }); // Create session with hashed refresh token diff --git a/src/services/meta-auth.service.ts b/src/services/meta-auth.service.ts index 1ad16b8..b016c2d 100644 --- a/src/services/meta-auth.service.ts +++ b/src/services/meta-auth.service.ts @@ -1,55 +1,58 @@ import { config } from '../config.js'; -interface MetaTokenResponse { - access_token: string; - token_type: string; - expires_in: number; +interface NonceValidateResponse { + is_valid: boolean; } -interface MetaProfile { +interface OculusUserResponse { id: string; - name: string; - email?: string; - picture?: { data?: { url?: string } }; + alias: string; + avatar_uri?: string; } -export async function exchangeMetaCode(code: string): Promise<{ accessToken: string }> { +/** + * Verify a Quest Platform SDK user proof (nonce) via Meta's server-side API. + * The app calls Users.getUserProof() on-device, sends the nonce + userId here, + * and we verify it with Meta using an app access token. + */ +export async function verifyUserProof(userId: string, nonce: string): Promise { + const accessToken = `OC|${config.meta.appId}|${config.meta.appSecret}`; const params = new URLSearchParams({ - client_id: config.meta.appId, - client_secret: config.meta.appSecret, - redirect_uri: config.meta.redirectUri, - code, + access_token: accessToken, + nonce, + user_id: userId, }); const res = await fetch( - `https://graph.facebook.com/v19.0/oauth/access_token?${params}`, + `https://graph.oculus.com/user_nonce_validate?${params}`, + { method: 'POST' }, ); if (!res.ok) { const body = await res.text(); - throw new Error(`Meta token exchange failed: ${res.status} ${body}`); + throw new Error(`Nonce validation request failed: ${res.status} ${body}`); } - const data = (await res.json()) as MetaTokenResponse; - return { accessToken: data.access_token }; + const data = (await res.json()) as NonceValidateResponse; + return data.is_valid; } -export async function fetchMetaProfile(accessToken: string): Promise<{ +/** + * Fetch Oculus user profile using an app access token. + */ +export async function fetchOculusProfile(userId: string): Promise<{ metaId: string; displayName: string; - email: string | null; - avatarUrl: string | null; }> { + const accessToken = `OC|${config.meta.appId}|${config.meta.appSecret}`; const res = await fetch( - `https://graph.facebook.com/v19.0/me?fields=id,name,email,picture.type(large)&access_token=${accessToken}`, + `https://graph.oculus.com/${userId}?fields=id,alias&access_token=${accessToken}`, ); if (!res.ok) { const body = await res.text(); - throw new Error(`Meta profile fetch failed: ${res.status} ${body}`); + throw new Error(`Oculus profile fetch failed: ${res.status} ${body}`); } - const data = (await res.json()) as MetaProfile; + const data = (await res.json()) as OculusUserResponse; return { metaId: data.id, - displayName: data.name, - email: data.email ?? null, - avatarUrl: data.picture?.data?.url ?? null, + displayName: data.alias, }; } diff --git a/src/types/api.ts b/src/types/api.ts index dadd802..168ff7d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,6 +1,7 @@ // ── Auth ────────────────────────────────────────────────── export interface MetaCallbackBody { - code: string; + userId: string; + nonce?: string; deviceInfo?: string; }