Skip to content
1 change: 1 addition & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `APPLE_ROOT_CERTIFICATES_PEM` - Apple root CA certs (PEM) for validating IAP receipts. [SERVER]
- `GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON` - Service account JSON for the Android Publisher API (subscriptions v2 get). `[SECRET]`
- `GOOGLE_PLAY_RTDN_PUSH_AUDIENCE` - Expected OIDC audience for Play Real-time Developer Notification Pub/Sub push (the HTTPS URL of POST /api/kilo-pass/play/notifications). `[SERVER]`
- `GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL` - Expected service-account email for the Play RTDN Pub/Sub push OIDC token; the token `email` claim must match it. [SERVER]
- `APPLE_APP_BUNDLE_ID` - iOS app bundle ID for Apple App Attest and Sign In verification. [SERVER]
- `NATIVE_ADMISSION_MODE` - Native admission enforcement mode: `off` (default), `report`, or `enforce`. [SERVER]
- `NATIVE_ADMISSION_SIMULATOR_BYPASS` - When `true` in non-production, bypasses Play Integrity API verification. [SERVER]
Expand Down
192 changes: 192 additions & 0 deletions apps/web/src/app/api/kilo-pass/play/notifications/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { captureException } from '@sentry/nextjs';
import { OAuth2Client } from 'google-auth-library';

import { getEnvVariable } from '@/lib/dotenvx';
import { processGooglePlayKiloPassNotification } from '@/lib/kilo-pass/google-play-notifications';
import { POST } from './route';

jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
}));

jest.mock('@/lib/dotenvx', () => ({
getEnvVariable: jest.fn(),
}));

jest.mock('@/lib/kilo-pass/google-play-notifications', () => ({
processGooglePlayKiloPassNotification: jest.fn(),
}));

jest.mock('google-auth-library');

const mockVerifyIdToken = jest.fn();
const mockProcess = jest.mocked(processGooglePlayKiloPassNotification);
const mockGetEnvVariable = jest.mocked(getEnvVariable);

(OAuth2Client as unknown as jest.Mock).mockImplementation(() => ({
verifyIdToken: mockVerifyIdToken,
}));

const AUDIENCE = 'https://app.example.com/api/kilo-pass/play/notifications';
const SERVICE_ACCOUNT_EMAIL = 'play-rtdn-push@example.iam.gserviceaccount.com';

function request(body: unknown, token?: string) {
const headers: Record<string, string> = { 'content-type': 'application/json' };
if (token !== undefined) {
headers.authorization = `Bearer ${token}`;
}
const req = new Request('https://app.example.com/api/kilo-pass/play/notifications', {
method: 'POST',
body: JSON.stringify(body),
headers,
});
return req;
}

describe('POST /api/kilo-pass/play/notifications', () => {
beforeEach(() => {
jest.clearAllMocks();
mockProcess.mockResolvedValue({ processed: true });
mockVerifyIdToken.mockResolvedValue({
getPayload: () => ({ email: SERVICE_ACCOUNT_EMAIL, email_verified: true }),
});
mockGetEnvVariable.mockImplementation(name => {
if (name === 'GOOGLE_PLAY_RTDN_PUSH_AUDIENCE') return AUDIENCE;
if (name === 'GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL') return SERVICE_ACCOUNT_EMAIL;
return '';
});
});

it('returns 401 when the Authorization bearer is missing', async () => {
const response = await POST(request({ message: { data: 'payload' } }));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 401 when the OIDC token is invalid', async () => {
mockVerifyIdToken.mockRejectedValueOnce(new Error('invalid token'));

const response = await POST(request({ message: { data: 'payload' } }, 'bad-token'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockVerifyIdToken).toHaveBeenCalledWith({
idToken: 'bad-token',
audience: AUDIENCE,
});
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 401 when GOOGLE_PLAY_RTDN_PUSH_AUDIENCE is not configured', async () => {
mockGetEnvVariable.mockImplementation(name =>
name === 'GOOGLE_PLAY_RTDN_PUSH_AUDIENCE' ? '' : SERVICE_ACCOUNT_EMAIL
);

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 401 when GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL is not configured', async () => {
mockGetEnvVariable.mockImplementation(name =>
name === 'GOOGLE_PLAY_RTDN_PUSH_AUDIENCE' ? AUDIENCE : ''
);

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockVerifyIdToken).not.toHaveBeenCalled();
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 401 when the token email does not match the service account', async () => {
mockVerifyIdToken.mockResolvedValueOnce({
getPayload: () => ({ email: 'attacker@example.com', email_verified: true }),
});

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 401 when the token email is not verified', async () => {
mockVerifyIdToken.mockResolvedValueOnce({
getPayload: () => ({ email: SERVICE_ACCOUNT_EMAIL, email_verified: false }),
});

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 401 when the token payload lacks an email', async () => {
mockVerifyIdToken.mockResolvedValueOnce({
getPayload: () => ({ email_verified: true }),
});

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'Unauthorized' });
expect(mockProcess).not.toHaveBeenCalled();
});

it('returns 400 when the Pub/Sub message data is missing', async () => {
const response = await POST(request({ message: {} }, 'valid-token'));

expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: 'Missing Pub/Sub message data' });
});

it('processes Play notification payloads', async () => {
const response = await POST(
request({ message: { data: 'cGF5bG9hZA==', messageId: 'msg-1' } }, 'valid-token')
);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({ processed: true });
expect(mockProcess).toHaveBeenCalledWith({
pubsubMessage: { data: 'cGF5bG9hZA==', messageId: 'msg-1' },
});
});

it('treats already-processed duplicate notifications as idempotent success', async () => {
mockProcess.mockResolvedValueOnce({ processed: true, status: 'already_processed' });

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(200);
expect(await response.json()).toEqual({ processed: true, status: 'already_processed' });
});

it('asks Pub/Sub to retry fresh in-flight duplicate notifications', async () => {
mockProcess.mockResolvedValueOnce({ processed: false, status: 'in_flight' });

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(503);
expect(await response.json()).toEqual({ processed: false, status: 'in_flight' });
});

it('captures processing failures without exposing details', async () => {
const error = new Error('bad payload');
mockProcess.mockRejectedValueOnce(error);

const response = await POST(request({ message: { data: 'payload' } }, 'valid-token'));

expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: 'Failed to process notification' });
expect(captureException).toHaveBeenCalledWith(error, {
tags: { source: 'google_play_kilo_pass_notification' },
});
});
});
70 changes: 70 additions & 0 deletions apps/web/src/app/api/kilo-pass/play/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { captureException } from '@sentry/nextjs';
import * as z from 'zod';
import { OAuth2Client } from 'google-auth-library';

import { getEnvVariable } from '@/lib/dotenvx';
import { processGooglePlayKiloPassNotification } from '@/lib/kilo-pass/google-play-notifications';

const GooglePlayNotificationBodySchema = z.object({
message: z.object({
data: z.string().min(1),
messageId: z.string().optional(),
}),
});

async function verifyGooglePlayRtdnToken(idToken: string): Promise<void> {
const audience = getEnvVariable('GOOGLE_PLAY_RTDN_PUSH_AUDIENCE');
if (!audience) {
throw new Error('GOOGLE_PLAY_RTDN_PUSH_AUDIENCE is not set');
}
const expectedEmail = getEnvVariable('GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL');
if (!expectedEmail) {
throw new Error('GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL is not set');
}
const client = new OAuth2Client();
const ticket = await client.verifyIdToken({ idToken, audience });
const payload = ticket.getPayload();
if (!payload?.email || payload.email_verified !== true) {
throw new Error('Play RTDN push token email is not verified');
}
if (payload.email !== expectedEmail) {
throw new Error('Play RTDN push token email does not match the configured service account');
}
}

export async function POST(request: Request) {
try {
const authorization = request.headers.get('authorization');
const bearerToken = authorization?.startsWith('Bearer ')
? authorization.slice('Bearer '.length)
: null;
if (!bearerToken) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

try {
await verifyGooglePlayRtdnToken(bearerToken);
} catch {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}

const body = GooglePlayNotificationBodySchema.safeParse(await request.json());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bot: Malformed request JSON falls through as a 500 instead of the intended 400 client error.

Suggested fix: Wrap await request.json() in a small parse-specific try/catch and return the same 400 response used for an invalid Pub/Sub envelope; keep processor failures in the outer 500 handler. Add a test using a request with an invalid JSON body.

if (!body.success) {
return Response.json({ error: 'Missing Pub/Sub message data' }, { status: 400 });
}

const result = await processGooglePlayKiloPassNotification({
pubsubMessage: {
data: body.data.message.data,
messageId: body.data.message.messageId,
},
});
if ('status' in result && result.status === 'in_flight') {
return Response.json(result, { status: 503 });
}
return Response.json(result);
} catch (error) {
captureException(error, { tags: { source: 'google_play_kilo_pass_notification' } });
return Response.json({ error: 'Failed to process notification' }, { status: 500 });
}
}
Loading
Loading