Support Quest Platform SDK auth with oculusId fallback
When the Data Use Checkup hasn't granted numeric ID access, the SDK returns oculusId (string username) instead. This makes nonce optional, skips nonce verification for non-numeric userIds, and uses oculusId as the metaId when numeric ID is unavailable.
This commit is contained in:
@@ -24,7 +24,6 @@ export const config = {
|
||||
meta: {
|
||||
appId: required('META_APP_ID'),
|
||||
appSecret: required('META_APP_SECRET'),
|
||||
redirectUri: required('META_REDIRECT_URI'),
|
||||
},
|
||||
|
||||
youtube: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
export interface MetaCallbackBody {
|
||||
code: string;
|
||||
userId: string;
|
||||
nonce?: string;
|
||||
deviceInfo?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user