Closer/functions/src/security/checkDeviceIntegrity.ts

90 lines
3.0 KiB
TypeScript

import * as functions from 'firebase-functions'
import { GoogleAuth } from 'google-auth-library'
const PACKAGE_NAME = 'app.closer'
const PLAY_INTEGRITY_URL =
`https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`
/**
* Verifies a Play Integrity API token server-side and returns whether the
* device meets basic integrity requirements.
*
* Called by [PlayIntegrityChecker] on app startup. Requires the caller to be
* authenticated — the Firebase Auth token is verified automatically by the
* Functions runtime.
*
* Setup required before this function works in production:
* 1. Enable the Play Integrity API in your Google Cloud project.
* 2. Link the Cloud project to your Play Console app (Play Console →
* Setup → API access → Link to Cloud project).
* 3. Grant the Cloud Functions service account the "Play Integrity API User"
* role in IAM, or use a dedicated service account key via
* GOOGLE_APPLICATION_CREDENTIALS.
*
* If the Play Integrity API is not configured the function fails-closed
* (returns passed: false). Configure the API and grant the service account
* the "Play Integrity API User" IAM role before deploying to production.
*/
export const checkDeviceIntegrity = functions.https.onCall(
async (data: { token?: string }, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'Caller must be authenticated.'
)
}
if (!context.app) {
throw new functions.https.HttpsError(
'failed-precondition',
'App Check verification required.'
)
}
const token = data?.token
if (!token || typeof token !== 'string') {
throw new functions.https.HttpsError(
'invalid-argument',
'token is required.'
)
}
try {
const verdicts = await decodeIntegrityToken(token)
const passed =
verdicts.includes('MEETS_DEVICE_INTEGRITY') ||
verdicts.includes('MEETS_STRONG_INTEGRITY')
return { passed, verdicts }
} catch (err) {
console.error('[checkDeviceIntegrity] verification failed:', err)
// Fail-closed: an unverifiable request is treated as failed, not passed.
// Ensure the Play Integrity API is enabled and the service account has
// "Play Integrity API User" role before deploying to production.
return { passed: false, verdicts: [], error: 'verification_unavailable' }
}
}
)
interface IntegrityResponse {
tokenPayloadExternal?: {
deviceIntegrity?: {
deviceRecognitionVerdict?: string[]
}
}
}
async function decodeIntegrityToken(token: string): Promise<string[]> {
const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/playintegrity'],
})
const client = await auth.getClient()
const response = await client.request<IntegrityResponse>({
url: PLAY_INTEGRITY_URL,
method: 'POST',
data: { integrity_token: token },
})
return (
response.data.tokenPayloadExternal?.deviceIntegrity
?.deviceRecognitionVerdict ?? []
)
}