173 lines
5 KiB
TypeScript
173 lines
5 KiB
TypeScript
export interface GooglePlaySubscriptionRtdn {
|
|
kind: 'subscription'
|
|
messageId: string
|
|
packageName: string
|
|
eventTimeMillis: number
|
|
purchaseToken: string
|
|
notificationType: number
|
|
}
|
|
|
|
export interface GooglePlayTestRtdn {
|
|
kind: 'test'
|
|
messageId: string
|
|
packageName: string
|
|
eventTimeMillis: number
|
|
}
|
|
|
|
export type GooglePlayRtdn = GooglePlaySubscriptionRtdn | GooglePlayTestRtdn
|
|
|
|
interface PubSubEnvelope {
|
|
message?: {
|
|
data?: unknown
|
|
messageId?: unknown
|
|
message_id?: unknown
|
|
}
|
|
}
|
|
|
|
interface RtdnPayload {
|
|
packageName?: unknown
|
|
eventTimeMillis?: unknown
|
|
subscriptionNotification?: {
|
|
notificationType?: unknown
|
|
purchaseToken?: unknown
|
|
}
|
|
testNotification?: {
|
|
version?: unknown
|
|
}
|
|
}
|
|
|
|
export interface GoogleOidcClaims {
|
|
aud?: unknown
|
|
email?: unknown
|
|
email_verified?: unknown
|
|
exp?: unknown
|
|
iss?: unknown
|
|
}
|
|
|
|
export class GooglePubSubError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
public readonly status: number,
|
|
) {
|
|
super(code)
|
|
this.name = 'GooglePubSubError'
|
|
}
|
|
}
|
|
|
|
function decodeBase64Utf8(value: string): string {
|
|
try {
|
|
const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
|
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
|
return new TextDecoder().decode(bytes)
|
|
} catch {
|
|
throw new GooglePubSubError('invalid_pubsub_data', 400)
|
|
}
|
|
}
|
|
|
|
export function validateGoogleOidcClaims(
|
|
claims: GoogleOidcClaims,
|
|
expectedAudience: string,
|
|
expectedEmail: string,
|
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
): void {
|
|
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
|
|
const expiry = typeof claims.exp === 'string' ? Number(claims.exp) : claims.exp
|
|
const verified = claims.email_verified === true || claims.email_verified === 'true'
|
|
if (
|
|
!audiences.includes(expectedAudience)
|
|
|| claims.email !== expectedEmail
|
|
|| !verified
|
|
|| (claims.iss !== 'https://accounts.google.com' && claims.iss !== 'accounts.google.com')
|
|
|| typeof expiry !== 'number'
|
|
|| !Number.isFinite(expiry)
|
|
|| expiry <= nowSeconds
|
|
) {
|
|
throw new GooglePubSubError('invalid_pubsub_identity', 401)
|
|
}
|
|
}
|
|
|
|
export async function verifyGooglePubSubIdentity(
|
|
req: Request,
|
|
fetchImpl: typeof fetch = fetch,
|
|
): Promise<void> {
|
|
const expectedAudience = Deno.env.get('GOOGLE_PLAY_PUBSUB_AUDIENCE') ?? ''
|
|
const expectedEmail = Deno.env.get('GOOGLE_PLAY_PUBSUB_SERVICE_ACCOUNT') ?? ''
|
|
if (!expectedAudience || !expectedEmail) {
|
|
throw new GooglePubSubError('google_pubsub_not_configured', 503)
|
|
}
|
|
|
|
const authorization = req.headers.get('Authorization') ?? ''
|
|
const match = /^Bearer\s+([^\s]+)$/i.exec(authorization)
|
|
if (!match) throw new GooglePubSubError('missing_pubsub_identity', 401)
|
|
|
|
let response: Response
|
|
try {
|
|
response = await fetchImpl(
|
|
`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(match[1])}`,
|
|
{
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(10_000),
|
|
},
|
|
)
|
|
} catch {
|
|
throw new GooglePubSubError('google_pubsub_identity_unavailable', 503)
|
|
}
|
|
if (!response.ok) throw new GooglePubSubError('invalid_pubsub_identity', 401)
|
|
const claims = await response.json() as GoogleOidcClaims
|
|
validateGoogleOidcClaims(claims, expectedAudience, expectedEmail)
|
|
}
|
|
|
|
export function parseGooglePlayRtdn(envelope: PubSubEnvelope): GooglePlayRtdn {
|
|
const messageId = envelope.message?.messageId ?? envelope.message?.message_id
|
|
const encodedData = envelope.message?.data
|
|
if (typeof messageId !== 'string' || !messageId || messageId.length > 256) {
|
|
throw new GooglePubSubError('invalid_pubsub_message_id', 400)
|
|
}
|
|
if (typeof encodedData !== 'string' || encodedData.length > 32_768) {
|
|
throw new GooglePubSubError('invalid_pubsub_data', 400)
|
|
}
|
|
|
|
let payload: RtdnPayload
|
|
try {
|
|
payload = JSON.parse(decodeBase64Utf8(encodedData)) as RtdnPayload
|
|
} catch (error) {
|
|
if (error instanceof GooglePubSubError) throw error
|
|
throw new GooglePubSubError('invalid_pubsub_data', 400)
|
|
}
|
|
const eventTimeMillis = Number(payload.eventTimeMillis)
|
|
if (
|
|
payload.packageName === 'com.d3ro.voice'
|
|
&& payload.testNotification
|
|
&& Number.isSafeInteger(eventTimeMillis)
|
|
) {
|
|
return {
|
|
kind: 'test',
|
|
messageId,
|
|
packageName: payload.packageName,
|
|
eventTimeMillis,
|
|
}
|
|
}
|
|
|
|
const notification = payload.subscriptionNotification
|
|
if (
|
|
payload.packageName !== 'com.d3ro.voice'
|
|
|| !notification
|
|
|| typeof notification.purchaseToken !== 'string'
|
|
|| notification.purchaseToken.length < 8
|
|
|| notification.purchaseToken.length > 4096
|
|
|| typeof notification.notificationType !== 'number'
|
|
|| !Number.isSafeInteger(notification.notificationType)
|
|
|| !Number.isSafeInteger(eventTimeMillis)
|
|
) {
|
|
throw new GooglePubSubError('invalid_google_play_notification', 400)
|
|
}
|
|
|
|
return {
|
|
kind: 'subscription',
|
|
messageId,
|
|
packageName: payload.packageName,
|
|
eventTimeMillis,
|
|
purchaseToken: notification.purchaseToken,
|
|
notificationType: notification.notificationType,
|
|
}
|
|
}
|