105 lines
2.7 KiB
TypeScript
105 lines
2.7 KiB
TypeScript
interface ServiceAccount {
|
|
client_email: string
|
|
private_key: string
|
|
project_id: string
|
|
}
|
|
|
|
interface CachedToken {
|
|
token: string
|
|
expiresAt: number
|
|
}
|
|
|
|
let serviceAccount: ServiceAccount | null = null
|
|
let privateKey: CryptoKey | null = null
|
|
let cachedToken: CachedToken | null = null
|
|
|
|
export function getServiceAccount(): ServiceAccount {
|
|
if (serviceAccount) return serviceAccount
|
|
|
|
const credentialsJson = Deno.env.get('GOOGLE_CREDENTIALS_JSON')
|
|
if (!credentialsJson?.trim()) {
|
|
throw new Error('GOOGLE_CREDENTIALS_JSON environment variable is required')
|
|
}
|
|
|
|
serviceAccount = JSON.parse(credentialsJson)
|
|
return serviceAccount!
|
|
}
|
|
|
|
async function getPrivateKey(): Promise<CryptoKey> {
|
|
if (privateKey) return privateKey
|
|
|
|
const pem = getServiceAccount().private_key
|
|
const pemContents = pem
|
|
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
|
.replace(/-----END PRIVATE KEY-----/, '')
|
|
.replace(/\s/g, '')
|
|
|
|
const der = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0))
|
|
|
|
privateKey = await crypto.subtle.importKey(
|
|
'pkcs8',
|
|
der.buffer,
|
|
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
)
|
|
|
|
return privateKey
|
|
}
|
|
|
|
function b64url(data: ArrayBuffer | string): string {
|
|
const str = typeof data === 'string'
|
|
? btoa(data)
|
|
: btoa(String.fromCharCode(...new Uint8Array(data)))
|
|
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
}
|
|
|
|
export async function getAccessToken(): Promise<string> {
|
|
const now = Date.now()
|
|
|
|
if (cachedToken && cachedToken.expiresAt > now + 60_000) {
|
|
return cachedToken.token
|
|
}
|
|
|
|
const sa = getServiceAccount()
|
|
const iat = Math.floor(now / 1000)
|
|
const exp = iat + 3600
|
|
|
|
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
const payload = b64url(JSON.stringify({
|
|
iss: sa.client_email,
|
|
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
|
aud: 'https://oauth2.googleapis.com/token',
|
|
exp,
|
|
iat
|
|
}))
|
|
|
|
const signingInput = `${header}.${payload}`
|
|
const key = await getPrivateKey()
|
|
const signature = await crypto.subtle.sign(
|
|
'RSASSA-PKCS1-v1_5',
|
|
key,
|
|
new TextEncoder().encode(signingInput)
|
|
)
|
|
|
|
const jwt = `${signingInput}.${b64url(signature)}`
|
|
|
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
assertion: jwt
|
|
})
|
|
})
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to get FCM access token: ${await res.text()}`)
|
|
}
|
|
|
|
const { access_token, expires_in } = await res.json()
|
|
cachedToken = { token: access_token, expiresAt: now + expires_in * 1000 }
|
|
|
|
return access_token
|
|
}
|