647 lines
19 KiB
TypeScript
647 lines
19 KiB
TypeScript
/**
|
|
* MotorSignal — Play Purchase Verification & Server Acknowledgement Module
|
|
* Communicates with Google Play Developer API v3 (Android Publisher API)
|
|
* to authoritatively verify and acknowledge subscriptions and one-time purchases.
|
|
*/
|
|
|
|
export interface PlayVerificationEnv {
|
|
GOOGLE_SERVICE_ACCOUNT_EMAIL?: string;
|
|
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?: string;
|
|
GOOGLE_PLAY_PACKAGE_NAME?: string;
|
|
}
|
|
|
|
export const AUTHORITATIVE_PACKAGE_NAME = 'app.motorsignal.motorcycle';
|
|
|
|
export const ALLOWED_PRODUCTS = {
|
|
PRO_MONTHLY: 'motorsignal_pro_monthly',
|
|
PRO_LIFETIME: 'motorsignal_pro_lifetime',
|
|
} as const;
|
|
|
|
export interface VerifyPurchaseRequest {
|
|
productId: string;
|
|
purchaseToken: string;
|
|
}
|
|
|
|
export type AcknowledgementState = 'acknowledged' | 'pendingRetry' | 'notApplicable';
|
|
|
|
export interface VerificationResponse {
|
|
schemaVersion: number;
|
|
productId: string;
|
|
plan: 'proMonthly' | 'proLifetime' | 'free';
|
|
status:
|
|
| 'proMonthlyActive'
|
|
| 'proMonthlyGrace'
|
|
| 'proMonthlyAccountHold'
|
|
| 'proMonthlyPaused'
|
|
| 'proMonthlyExpired'
|
|
| 'proLifetime'
|
|
| 'unknown'
|
|
| 'syncPending';
|
|
verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked';
|
|
acknowledgementState: AcknowledgementState;
|
|
verifiedAt: string;
|
|
expiresAt: string | null;
|
|
tokenHash: string;
|
|
error?: string;
|
|
}
|
|
|
|
// In-memory token cache for short-lived Google OAuth access token
|
|
let cachedAccessToken: { token: string; expiresAtMs: number } | null = null;
|
|
|
|
/**
|
|
* Derives SHA-256 fingerprint of purchase token for replay tracking / cache key.
|
|
*/
|
|
export async function computeTokenHash(token: string): Promise<string> {
|
|
const enc = new TextEncoder().encode(token);
|
|
const digest = await crypto.subtle.digest('SHA-256', enc);
|
|
return Array.from(new Uint8Array(digest))
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Generates an OAuth2 access token for Google APIs via a service account private key.
|
|
*/
|
|
export async function getGoogleAccessToken(
|
|
clientEmail: string,
|
|
privateKeyPem: string
|
|
): Promise<string> {
|
|
const nowMs = Date.now();
|
|
if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60000) {
|
|
return cachedAccessToken.token;
|
|
}
|
|
|
|
const nowSec = Math.floor(nowMs / 1000);
|
|
const expSec = nowSec + 3600;
|
|
|
|
const header = {
|
|
alg: 'RS256',
|
|
typ: 'JWT',
|
|
};
|
|
|
|
const claimSet = {
|
|
iss: clientEmail,
|
|
scope: 'https://www.googleapis.com/auth/androidpublisher',
|
|
aud: 'https://oauth2.googleapis.com/token',
|
|
exp: expSec,
|
|
iat: nowSec,
|
|
};
|
|
|
|
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
const encodedClaimSet = base64UrlEncode(JSON.stringify(claimSet));
|
|
const signingInput = `${encodedHeader}.${encodedClaimSet}`;
|
|
|
|
const binaryKey = pemToBinary(privateKeyPem);
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'pkcs8',
|
|
binaryKey,
|
|
{
|
|
name: 'RSASSA-PKCS1-v1_5',
|
|
hash: { name: 'SHA-256' },
|
|
},
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const signature = await crypto.subtle.sign(
|
|
'RSASSA-PKCS1-v1_5',
|
|
cryptoKey,
|
|
new TextEncoder().encode(signingInput)
|
|
);
|
|
|
|
const jwt = `${signingInput}.${base64UrlEncode(signature)}`;
|
|
|
|
const tokenResp = 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 (!tokenResp.ok) {
|
|
const errText = await tokenResp.text().catch(() => '');
|
|
throw new Error(`Google OAuth exchange failed (${tokenResp.status}): ${errText}`);
|
|
}
|
|
|
|
const tokenData = (await tokenResp.json()) as {
|
|
access_token: string;
|
|
expires_in: number;
|
|
};
|
|
|
|
cachedAccessToken = {
|
|
token: tokenData.access_token,
|
|
expiresAtMs: nowMs + (tokenData.expires_in || 3600) * 1000,
|
|
};
|
|
|
|
return cachedAccessToken.token;
|
|
}
|
|
|
|
/**
|
|
* Authoritatively verifies and acknowledges a Google Play purchase token.
|
|
*/
|
|
export async function verifyPlayPurchase(
|
|
req: VerifyPurchaseRequest,
|
|
env: PlayVerificationEnv
|
|
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
|
|
const { productId, purchaseToken } = req;
|
|
|
|
if (!productId || typeof productId !== 'string') {
|
|
return { status: 400, body: { error: 'Invalid or missing productId', code: 'INVALID_PRODUCT' } };
|
|
}
|
|
|
|
if (!purchaseToken || typeof purchaseToken !== 'string' || purchaseToken.length < 10) {
|
|
return { status: 400, body: { error: 'Invalid or missing purchaseToken', code: 'INVALID_PURCHASE' } };
|
|
}
|
|
|
|
if (
|
|
productId !== ALLOWED_PRODUCTS.PRO_MONTHLY &&
|
|
productId !== ALLOWED_PRODUCTS.PRO_LIFETIME
|
|
) {
|
|
return {
|
|
status: 400,
|
|
body: { error: `Product '${productId}' is not recognized`, code: 'INVALID_PRODUCT' },
|
|
};
|
|
}
|
|
|
|
const tokenHash = await computeTokenHash(purchaseToken);
|
|
const packageName = env.GOOGLE_PLAY_PACKAGE_NAME || AUTHORITATIVE_PACKAGE_NAME;
|
|
const clientEmail = env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
|
const privateKey = env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY;
|
|
|
|
if (!clientEmail || !privateKey) {
|
|
return {
|
|
status: 503,
|
|
body: {
|
|
error: 'Google Play verification credentials not configured on server',
|
|
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
|
|
},
|
|
};
|
|
}
|
|
|
|
let accessToken: string;
|
|
try {
|
|
accessToken = await getGoogleAccessToken(clientEmail, privateKey);
|
|
} catch (err: unknown) {
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
console.error(`[PlayVerification] [hash=${tokenPrefix}] OAuth token acquisition failed`, err);
|
|
return {
|
|
status: 502,
|
|
body: {
|
|
error: 'Failed to authenticate with Google Developer API',
|
|
code: 'GOOGLE_TEMPORARILY_UNAVAILABLE',
|
|
},
|
|
};
|
|
}
|
|
|
|
if (productId === ALLOWED_PRODUCTS.PRO_MONTHLY) {
|
|
return await verifySubscription(packageName, purchaseToken, tokenHash, accessToken);
|
|
} else {
|
|
return await verifyOneTimeProduct(packageName, productId, purchaseToken, tokenHash, accessToken);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts latest valid expiration timestamp across all line items matching the target product.
|
|
*/
|
|
export function extractLatestExpiry(
|
|
lineItems: Array<{ productId?: string; expiryTime?: string }> | undefined,
|
|
targetProductId: string
|
|
): { expiryTimeIso: string | null; expiryTimeMs: number } {
|
|
if (!lineItems || lineItems.length === 0) {
|
|
return { expiryTimeIso: null, expiryTimeMs: NaN };
|
|
}
|
|
|
|
let latestMs = NaN;
|
|
let latestIso: string | null = null;
|
|
|
|
for (const item of lineItems) {
|
|
if (item.productId && item.productId !== targetProductId) {
|
|
continue;
|
|
}
|
|
if (item.expiryTime) {
|
|
const ms = Date.parse(item.expiryTime);
|
|
if (!isNaN(ms) && (isNaN(latestMs) || ms > latestMs)) {
|
|
latestMs = ms;
|
|
latestIso = new Date(ms).toISOString();
|
|
}
|
|
}
|
|
}
|
|
|
|
return { expiryTimeIso: latestIso, expiryTimeMs: latestMs };
|
|
}
|
|
|
|
/**
|
|
* Acknowledges a subscription token server-side via purchases.subscriptions.acknowledge.
|
|
*/
|
|
export async function acknowledgeSubscription(
|
|
packageName: string,
|
|
subscriptionId: string,
|
|
token: string,
|
|
accessToken: string,
|
|
tokenHash: string
|
|
): Promise<{ success: boolean; isRetryable: boolean }> {
|
|
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
|
|
packageName
|
|
)}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent(
|
|
token
|
|
)}:acknowledge`;
|
|
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (res.ok || res.status === 204) {
|
|
return { success: true, isRetryable: false };
|
|
}
|
|
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
const text = await res.text().catch(() => '');
|
|
|
|
// Idempotency: If already acknowledged, Google may respond with 400 'Purchase already acknowledged' or similar
|
|
if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) {
|
|
return { success: true, isRetryable: false };
|
|
}
|
|
|
|
console.error(
|
|
`[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge failed: status=${res.status} body=${text}`
|
|
);
|
|
|
|
const isRetryable = res.status === 429 || res.status >= 500;
|
|
return { success: false, isRetryable };
|
|
} catch (e) {
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
console.error(`[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge network exception:`, e);
|
|
return { success: false, isRetryable: true };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acknowledges a one-time product token server-side via purchases.products.acknowledge.
|
|
*/
|
|
export async function acknowledgeOneTimeProduct(
|
|
packageName: string,
|
|
productId: string,
|
|
token: string,
|
|
accessToken: string,
|
|
tokenHash: string
|
|
): Promise<{ success: boolean; isRetryable: boolean }> {
|
|
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
|
|
packageName
|
|
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(
|
|
token
|
|
)}:acknowledge`;
|
|
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
if (res.ok || res.status === 204) {
|
|
return { success: true, isRetryable: false };
|
|
}
|
|
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
const text = await res.text().catch(() => '');
|
|
|
|
// Idempotency check for product acknowledgement
|
|
if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) {
|
|
return { success: true, isRetryable: false };
|
|
}
|
|
|
|
console.error(
|
|
`[PlayVerification] [hash=${tokenPrefix}] Product acknowledge failed: status=${res.status} body=${text}`
|
|
);
|
|
|
|
const isRetryable = res.status === 429 || res.status >= 500;
|
|
return { success: false, isRetryable };
|
|
} catch (e) {
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
console.error(`[PlayVerification] [hash=${tokenPrefix}] Product acknowledge network exception:`, e);
|
|
return { success: false, isRetryable: true };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies subscription using purchases.subscriptionsv2.get
|
|
*/
|
|
async function verifySubscription(
|
|
packageName: string,
|
|
token: string,
|
|
tokenHash: string,
|
|
accessToken: string
|
|
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
|
|
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
|
|
packageName
|
|
)}/purchases/subscriptionsv2/tokens/${encodeURIComponent(token)}`;
|
|
|
|
const res = await fetch(endpoint, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
});
|
|
|
|
if (res.status === 404) {
|
|
return {
|
|
status: 404,
|
|
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
|
|
};
|
|
}
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`);
|
|
return {
|
|
status: 502,
|
|
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
|
};
|
|
}
|
|
|
|
if (res.status === 429) {
|
|
return {
|
|
status: 429,
|
|
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
|
|
};
|
|
}
|
|
|
|
if (!res.ok) {
|
|
return {
|
|
status: 502,
|
|
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
|
};
|
|
}
|
|
|
|
const sub = (await res.json()) as {
|
|
subscriptionState?: string;
|
|
acknowledgementState?: string;
|
|
lineItems?: Array<{
|
|
productId?: string;
|
|
expiryTime?: string;
|
|
}>;
|
|
};
|
|
|
|
const { expiryTimeIso, expiryTimeMs } = extractLatestExpiry(
|
|
sub.lineItems,
|
|
ALLOWED_PRODUCTS.PRO_MONTHLY
|
|
);
|
|
const nowMs = Date.now();
|
|
|
|
const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED';
|
|
let plan: 'proMonthly' | 'free' = 'proMonthly';
|
|
let status: VerificationResponse['status'] = 'proMonthlyActive';
|
|
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
|
|
|
|
switch (state) {
|
|
case 'SUBSCRIPTION_STATE_ACTIVE':
|
|
status = 'proMonthlyActive';
|
|
verificationStatus = 'verified';
|
|
break;
|
|
|
|
case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD':
|
|
status = 'proMonthlyGrace';
|
|
verificationStatus = 'verified';
|
|
break;
|
|
|
|
case 'SUBSCRIPTION_STATE_ON_HOLD':
|
|
status = 'proMonthlyAccountHold';
|
|
verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro
|
|
break;
|
|
|
|
case 'SUBSCRIPTION_STATE_PAUSED':
|
|
status = 'proMonthlyPaused';
|
|
verificationStatus = 'verified'; // Explicit Paused state; denies Pro access
|
|
break;
|
|
|
|
case 'SUBSCRIPTION_STATE_CANCELED':
|
|
// Canceled: user keeps entitlement until paid period expires
|
|
if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) {
|
|
status = 'proMonthlyActive';
|
|
verificationStatus = 'verified';
|
|
} else {
|
|
status = 'proMonthlyExpired';
|
|
verificationStatus = 'expired';
|
|
}
|
|
break;
|
|
|
|
case 'SUBSCRIPTION_STATE_EXPIRED':
|
|
status = 'proMonthlyExpired';
|
|
verificationStatus = 'expired';
|
|
break;
|
|
|
|
case 'SUBSCRIPTION_STATE_PENDING':
|
|
status = 'syncPending';
|
|
verificationStatus = 'unverified';
|
|
break;
|
|
|
|
default:
|
|
status = 'unknown';
|
|
verificationStatus = 'unverified';
|
|
break;
|
|
}
|
|
|
|
// Server-Side Acknowledgement
|
|
// Only acknowledge if purchase is in active/entitled state and not already acknowledged.
|
|
let ackState: AcknowledgementState = 'notApplicable';
|
|
const isAlreadyAcknowledged =
|
|
sub.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED';
|
|
|
|
const shouldAcknowledge =
|
|
(status === 'proMonthlyActive' ||
|
|
status === 'proMonthlyGrace' ||
|
|
status === 'proMonthlyAccountHold') &&
|
|
verificationStatus === 'verified';
|
|
|
|
if (shouldAcknowledge) {
|
|
if (isAlreadyAcknowledged) {
|
|
ackState = 'acknowledged';
|
|
} else {
|
|
const ackResult = await acknowledgeSubscription(
|
|
packageName,
|
|
ALLOWED_PRODUCTS.PRO_MONTHLY,
|
|
token,
|
|
accessToken,
|
|
tokenHash
|
|
);
|
|
if (ackResult.success) {
|
|
ackState = 'acknowledged';
|
|
} else {
|
|
// Verification succeeded, but acknowledgement requires retry
|
|
ackState = 'pendingRetry';
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
schemaVersion: 1,
|
|
productId: ALLOWED_PRODUCTS.PRO_MONTHLY,
|
|
plan,
|
|
status,
|
|
verificationStatus,
|
|
acknowledgementState: ackState,
|
|
verifiedAt: new Date().toISOString(),
|
|
expiresAt: expiryTimeIso,
|
|
tokenHash,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verifies one-time lifetime purchase using purchases.products.get
|
|
*/
|
|
async function verifyOneTimeProduct(
|
|
packageName: string,
|
|
productId: string,
|
|
token: string,
|
|
tokenHash: string,
|
|
accessToken: string
|
|
): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> {
|
|
const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(
|
|
packageName
|
|
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}`;
|
|
|
|
const res = await fetch(endpoint, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/json',
|
|
},
|
|
});
|
|
|
|
if (res.status === 404) {
|
|
return {
|
|
status: 404,
|
|
body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' },
|
|
};
|
|
}
|
|
|
|
if (res.status === 401 || res.status === 403) {
|
|
const tokenPrefix = tokenHash.substring(0, 8);
|
|
console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`);
|
|
return {
|
|
status: 502,
|
|
body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
|
};
|
|
}
|
|
|
|
if (res.status === 429) {
|
|
return {
|
|
status: 429,
|
|
body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' },
|
|
};
|
|
}
|
|
|
|
if (!res.ok) {
|
|
return {
|
|
status: 502,
|
|
body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' },
|
|
};
|
|
}
|
|
|
|
const prod = (await res.json()) as {
|
|
purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending
|
|
consumptionState?: number;
|
|
acknowledgementState?: number; // 0 = Yet to be acknowledged, 1 = Acknowledged
|
|
};
|
|
|
|
const purchaseState = prod.purchaseState ?? 0;
|
|
let status: VerificationResponse['status'] = 'proLifetime';
|
|
let verificationStatus: VerificationResponse['verificationStatus'] = 'verified';
|
|
|
|
if (purchaseState === 0) {
|
|
// Purchased & valid
|
|
status = 'proLifetime';
|
|
verificationStatus = 'verified';
|
|
} else if (purchaseState === 1) {
|
|
// Canceled / refunded
|
|
status = 'unknown';
|
|
verificationStatus = 'revoked';
|
|
} else if (purchaseState === 2) {
|
|
// Pending
|
|
status = 'syncPending';
|
|
verificationStatus = 'unverified';
|
|
}
|
|
|
|
// Server-Side Acknowledgement for Lifetime
|
|
let ackState: AcknowledgementState = 'notApplicable';
|
|
const isAlreadyAcknowledged = prod.acknowledgementState === 1;
|
|
|
|
if (purchaseState === 0 && verificationStatus === 'verified') {
|
|
if (isAlreadyAcknowledged) {
|
|
ackState = 'acknowledged';
|
|
} else {
|
|
const ackResult = await acknowledgeOneTimeProduct(
|
|
packageName,
|
|
productId,
|
|
token,
|
|
accessToken,
|
|
tokenHash
|
|
);
|
|
if (ackResult.success) {
|
|
ackState = 'acknowledged';
|
|
} else {
|
|
ackState = 'pendingRetry';
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
schemaVersion: 1,
|
|
productId: ALLOWED_PRODUCTS.PRO_LIFETIME,
|
|
plan: 'proLifetime',
|
|
status,
|
|
verificationStatus,
|
|
acknowledgementState: ackState,
|
|
verifiedAt: new Date().toISOString(),
|
|
expiresAt: null,
|
|
tokenHash,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Helpers for PKCS#8 PEM decoding and Base64URL
|
|
function pemToBinary(pem: string): ArrayBuffer {
|
|
const cleanPem = pem
|
|
.replace(/-----BEGIN[ A-Z0-9_-]+-----/g, '')
|
|
.replace(/-----END[ A-Z0-9_-]+-----/g, '')
|
|
.replace(/\s+/g, '');
|
|
const raw = atob(cleanPem);
|
|
const bytes = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) {
|
|
bytes[i] = raw.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
}
|
|
|
|
function base64UrlEncode(input: string | ArrayBuffer): string {
|
|
let base64: string;
|
|
if (typeof input === 'string') {
|
|
base64 = btoa(input);
|
|
} else {
|
|
const bytes = new Uint8Array(input);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
base64 = btoa(binary);
|
|
}
|
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|