upt
This commit is contained in:
+257
-7
@@ -15,6 +15,10 @@ type GoogleRequestBody = {
|
||||
accessTokenEnv?: string;
|
||||
apiKey?: string;
|
||||
apiKeyEnv?: string;
|
||||
serviceAccountJson?: GoogleServiceAccountJson | string;
|
||||
serviceAccountJsonEnv?: string;
|
||||
serviceAccountScopes?: string[] | string;
|
||||
serviceAccountSubject?: string;
|
||||
};
|
||||
|
||||
type TokenExchangeBody = {
|
||||
@@ -33,6 +37,18 @@ type ServiceAccountTokenBody = {
|
||||
subject?: string;
|
||||
serviceAccountEmail?: string;
|
||||
privateKey?: string;
|
||||
serviceAccountJson?: GoogleServiceAccountJson | string;
|
||||
serviceAccountJsonEnv?: string;
|
||||
};
|
||||
|
||||
type GoogleServiceAccountJson = {
|
||||
type?: string;
|
||||
project_id?: string;
|
||||
private_key_id?: string;
|
||||
private_key?: string;
|
||||
client_email?: string;
|
||||
client_id?: string;
|
||||
token_uri?: string;
|
||||
};
|
||||
|
||||
type SheetWriteByUrlBody = {
|
||||
@@ -238,8 +254,17 @@ app.post("/google/oauth/token", async (req, res) => {
|
||||
app.post("/google/oauth/service-account-token", async (req, res) => {
|
||||
try {
|
||||
const body = req.body as ServiceAccountTokenBody;
|
||||
const email = body.serviceAccountEmail || req.header("x-google-service-account-email") || process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
||||
const rawPrivateKey = body.privateKey || req.header("x-google-private-key") || process.env.GOOGLE_PRIVATE_KEY;
|
||||
const serviceAccountJson = readServiceAccountJson(req, body);
|
||||
const email =
|
||||
body.serviceAccountEmail ||
|
||||
serviceAccountJson?.client_email ||
|
||||
req.header("x-google-service-account-email") ||
|
||||
process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
|
||||
const rawPrivateKey =
|
||||
body.privateKey ||
|
||||
serviceAccountJson?.private_key ||
|
||||
req.header("x-google-private-key") ||
|
||||
process.env.GOOGLE_PRIVATE_KEY;
|
||||
const privateKey = rawPrivateKey?.replace(/\\n/g, "\n");
|
||||
const scope = body.scope || body.scopes?.join(" ") || req.header("x-google-scopes") || process.env.GOOGLE_SCOPES;
|
||||
|
||||
@@ -254,7 +279,7 @@ app.post("/google/oauth/service-account-token", async (req, res) => {
|
||||
const claimSet: Record<string, string | number> = {
|
||||
iss: email,
|
||||
scope,
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
aud: serviceAccountJson?.token_uri || "https://oauth2.googleapis.com/token",
|
||||
iat: now,
|
||||
exp: now + 3600
|
||||
};
|
||||
@@ -269,7 +294,7 @@ app.post("/google/oauth/service-account-token", async (req, res) => {
|
||||
assertion
|
||||
});
|
||||
|
||||
await pipeGoogleForm(res, "https://oauth2.googleapis.com/token", params);
|
||||
await pipeGoogleForm(res, serviceAccountJson?.token_uri || "https://oauth2.googleapis.com/token", params);
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
@@ -320,6 +345,7 @@ app.get("/google/configuration", (_req, res) => {
|
||||
{ name: "GOOGLE_SERVICE_ACCOUNT_EMAIL", usedBy: ["/google/oauth/service-account-token"], alternatives: ["serviceAccountEmail", "X-Google-Service-Account-Email"] },
|
||||
{ name: "GOOGLE_PRIVATE_KEY", usedBy: ["/google/oauth/service-account-token"], alternatives: ["privateKey", "X-Google-Private-Key"] },
|
||||
{ name: "GOOGLE_SCOPES", usedBy: ["/google/oauth/service-account-token"], alternatives: ["scope", "scopes", "X-Google-Scopes"] },
|
||||
{ name: "GOOGLE_SERVICE_ACCOUNT_JSON", usedBy: ["/google/oauth/service-account-token", "Google product endpoints", "/google/request"], alternatives: ["serviceAccountJson", "serviceAccountJsonEnv", "X-Google-Service-Account-Json", "X-Google-Service-Account-Json-Env"] },
|
||||
{ name: "GOOGLE_ACCESS_TOKEN", usedBy: ["Google product endpoints", "/google/request"], alternatives: ["Authorization: Bearer", "accessToken", "accessTokenEnv", "X-Google-Access-Token", "X-Google-Access-Token-Env"] },
|
||||
{ name: "GOOGLE_API_KEY", usedBy: ["Google product endpoints", "/google/request"], alternatives: ["apiKey", "apiKeyEnv", "X-Google-Api-Key", "X-Google-Api-Key-Env"] }
|
||||
],
|
||||
@@ -863,6 +889,7 @@ app.post("/google/request", async (req, res) => {
|
||||
try {
|
||||
const requestBody = req.body as GoogleRequestBody;
|
||||
applyCredentialHeaders(req, requestBody);
|
||||
await applyServiceAccountAuthorization(req, requestBody);
|
||||
const targetUrl = buildGoogleUrl(requestBody);
|
||||
const headers = buildGoogleHeaders(req, requestBody);
|
||||
const method = (requestBody.method || "GET").toUpperCase();
|
||||
@@ -919,13 +946,14 @@ async function callGoogle(req: Request, res: Response, options: GoogleRouteOptio
|
||||
};
|
||||
|
||||
applyCredentialHeaders(req, requestBody);
|
||||
await applyServiceAccountAuthorization(req, requestBody);
|
||||
const targetUrl = buildGoogleUrl(requestBody);
|
||||
const headers = buildGoogleHeaders(req, requestBody);
|
||||
const method = (requestBody.method || "GET").toUpperCase();
|
||||
const init: RequestInit = { method, headers };
|
||||
|
||||
if (!["GET", "HEAD"].includes(method) && options.body !== undefined) {
|
||||
init.body = JSON.stringify(options.body);
|
||||
init.body = JSON.stringify(stripCredentialFields(options.body));
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, init);
|
||||
@@ -958,11 +986,102 @@ function readBodyString(body: unknown, key: string): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function stripCredentialFields(body: JsonValue): JsonValue {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const credentialFields = new Set([
|
||||
"accessToken",
|
||||
"accessTokenEnv",
|
||||
"apiKey",
|
||||
"apiKeyEnv",
|
||||
"serviceAccountJson",
|
||||
"serviceAccountJsonEnv",
|
||||
"serviceAccountScopes",
|
||||
"serviceAccountSubject"
|
||||
]);
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (!credentialFields.has(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyCredentialHeaders(req: Request, body: GoogleRequestBody): void {
|
||||
body.accessToken ||= req.header("x-google-access-token");
|
||||
body.accessTokenEnv ||= req.header("x-google-access-token-env");
|
||||
body.apiKey ||= req.header("x-google-api-key");
|
||||
body.apiKeyEnv ||= req.header("x-google-api-key-env");
|
||||
body.serviceAccountJson ||= req.header("x-google-service-account-json");
|
||||
body.serviceAccountJsonEnv ||= req.header("x-google-service-account-json-env");
|
||||
body.serviceAccountScopes ||= req.header("x-google-service-account-scopes");
|
||||
body.serviceAccountSubject ||= req.header("x-google-service-account-subject");
|
||||
}
|
||||
|
||||
async function applyServiceAccountAuthorization(req: Request, body: GoogleRequestBody): Promise<void> {
|
||||
if (body.accessToken || body.accessTokenEnv || readBearerToken(req)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceAccountJson = readServiceAccountJson(req, body);
|
||||
if (!serviceAccountJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopes = readServiceAccountScopes(req, body);
|
||||
if (scopes.length === 0) {
|
||||
throw new Error("Service account authentication requires serviceAccountScopes, X-Google-Service-Account-Scopes, or GOOGLE_SCOPES.");
|
||||
}
|
||||
|
||||
body.accessToken = await createServiceAccountAccessToken(
|
||||
serviceAccountJson,
|
||||
scopes.join(" "),
|
||||
body.serviceAccountSubject || req.header("x-google-service-account-subject") || undefined
|
||||
);
|
||||
}
|
||||
|
||||
function readServiceAccountJson(req: Request, body?: GoogleRequestBody | ServiceAccountTokenBody): GoogleServiceAccountJson | undefined {
|
||||
const bodyValue = body && "serviceAccountJson" in body ? body.serviceAccountJson : undefined;
|
||||
const envName = body && "serviceAccountJsonEnv" in body ? body.serviceAccountJsonEnv : undefined;
|
||||
const raw =
|
||||
bodyValue ||
|
||||
req.header("x-google-service-account-json") ||
|
||||
readEnvValue(envName) ||
|
||||
readEnvValue(req.header("x-google-service-account-json-env")) ||
|
||||
process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
|
||||
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
return raw;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as GoogleServiceAccountJson;
|
||||
} catch {
|
||||
throw new Error("serviceAccountJson must be a valid Google service account JSON object or JSON string.");
|
||||
}
|
||||
}
|
||||
|
||||
function readServiceAccountScopes(req: Request, body: GoogleRequestBody): string[] {
|
||||
const value =
|
||||
body.serviceAccountScopes ||
|
||||
readBodyString(req.body, "serviceAccountScopes") ||
|
||||
req.header("x-google-service-account-scopes") ||
|
||||
process.env.GOOGLE_SCOPES;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
return parseScopes(typeof value === "string" ? value : undefined) || [];
|
||||
}
|
||||
|
||||
function requireQueryString(req: Request, res: Response, key: string): string | undefined {
|
||||
@@ -1102,6 +1221,69 @@ async function pipeGoogleForm(res: Response, url: string, params: URLSearchParam
|
||||
await relayResponse(res, response);
|
||||
}
|
||||
|
||||
async function createServiceAccountAccessToken(
|
||||
serviceAccountJson: GoogleServiceAccountJson,
|
||||
scope: string,
|
||||
subject?: string
|
||||
): Promise<string> {
|
||||
const email = serviceAccountJson.client_email;
|
||||
const rawPrivateKey = serviceAccountJson.private_key;
|
||||
const privateKey = rawPrivateKey?.replace(/\\n/g, "\n");
|
||||
const tokenUri = serviceAccountJson.token_uri || "https://oauth2.googleapis.com/token";
|
||||
|
||||
if (!email || !privateKey) {
|
||||
throw new Error("Google service account JSON must contain client_email and private_key.");
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const claimSet: Record<string, string | number> = {
|
||||
iss: email,
|
||||
scope,
|
||||
aud: tokenUri,
|
||||
iat: now,
|
||||
exp: now + 3600
|
||||
};
|
||||
|
||||
if (subject) {
|
||||
claimSet.sub = subject;
|
||||
}
|
||||
|
||||
const assertion = signJwt({ alg: "RS256", typ: "JWT" }, claimSet, privateKey);
|
||||
const response = await fetch(tokenUri, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion
|
||||
})
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Service account token request failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const accessToken = typeof payload === "object" && payload && "access_token" in payload
|
||||
? (payload as Record<string, unknown>).access_token
|
||||
: undefined;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
throw new Error("Service account token response did not contain access_token.");
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function relayResponse(res: Response, response: globalThis.Response): Promise<void> {
|
||||
const contentType = response.headers.get("content-type") || "application/json";
|
||||
const text = await response.text();
|
||||
@@ -1438,6 +1620,8 @@ function buildOpenApiSchemas(): Record<string, unknown> {
|
||||
properties: {
|
||||
serviceAccountEmail: { type: "string", description: "Alternativa k X-Google-Service-Account-Email nebo env GOOGLE_SERVICE_ACCOUNT_EMAIL." },
|
||||
privateKey: { type: "string", description: "Alternativa k X-Google-Private-Key nebo env GOOGLE_PRIVATE_KEY. Podporuje \\n escapovani." },
|
||||
serviceAccountJson: { $ref: "#/components/schemas/GoogleServiceAccountJson" },
|
||||
serviceAccountJsonEnv: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" },
|
||||
scopes: { type: "array", items: { type: "string" }, description: "Alternativa k X-Google-Scopes nebo env GOOGLE_SCOPES." },
|
||||
scope: { type: "string" },
|
||||
subject: { type: "string", description: "Workspace user pro domain-wide delegation." }
|
||||
@@ -1456,7 +1640,31 @@ function buildOpenApiSchemas(): Record<string, unknown> {
|
||||
accessToken: { type: "string", description: "Alternativa k Authorization Bearer nebo X-Google-Access-Token." },
|
||||
accessTokenEnv: { type: "string", example: "GOOGLE_ACCESS_TOKEN", description: "Jmeno env promenne s access tokenem." },
|
||||
apiKey: { type: "string", description: "Alternativa k X-Google-Api-Key nebo GOOGLE_API_KEY." },
|
||||
apiKeyEnv: { type: "string", example: "GOOGLE_API_KEY", description: "Jmeno env promenne s API key." }
|
||||
apiKeyEnv: { type: "string", example: "GOOGLE_API_KEY", description: "Jmeno env promenne s API key." },
|
||||
serviceAccountJson: { $ref: "#/components/schemas/GoogleServiceAccountJson" },
|
||||
serviceAccountJsonEnv: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" },
|
||||
serviceAccountScopes: {
|
||||
oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
|
||||
description: "Scopes pro automaticke vystaveni tokenu ze service account JSON."
|
||||
},
|
||||
serviceAccountSubject: { type: "string", description: "Volitelny Workspace uzivatel pro domain-wide delegation." }
|
||||
}
|
||||
},
|
||||
GoogleServiceAccountJson: {
|
||||
type: "object",
|
||||
required: ["client_email", "private_key"],
|
||||
properties: {
|
||||
type: { type: "string", example: "service_account" },
|
||||
project_id: { type: "string" },
|
||||
private_key_id: { type: "string" },
|
||||
private_key: { type: "string", description: "Private key ze service account JSON. Secret se nevraci ve vystupu." },
|
||||
client_email: { type: "string", description: "Service account email pouzity jako JWT iss." },
|
||||
client_id: { type: "string" },
|
||||
auth_uri: { type: "string" },
|
||||
token_uri: { type: "string", example: "https://oauth2.googleapis.com/token" },
|
||||
auth_provider_x509_cert_url: { type: "string" },
|
||||
client_x509_cert_url: { type: "string" },
|
||||
universe_domain: { type: "string", example: "googleapis.com" }
|
||||
}
|
||||
},
|
||||
Calendar: {
|
||||
@@ -1566,7 +1774,14 @@ function buildOpenApiSchemas(): Record<string, unknown> {
|
||||
example: [["Datum", "Castka"], ["2026-06-15", 1234]]
|
||||
},
|
||||
accessToken: { type: "string", description: "Volitelne. Lepsi je poslat Authorization: Bearer <token>." },
|
||||
accessTokenEnv: { type: "string", description: "Volitelne jmeno env var s access tokenem." }
|
||||
accessTokenEnv: { type: "string", description: "Volitelne jmeno env var s access tokenem." },
|
||||
serviceAccountJson: { $ref: "#/components/schemas/GoogleServiceAccountJson" },
|
||||
serviceAccountJsonEnv: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" },
|
||||
serviceAccountScopes: {
|
||||
oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
|
||||
example: ["https://www.googleapis.com/auth/spreadsheets"],
|
||||
description: "Scopes pro automaticke vystaveni tokenu ze service account JSON."
|
||||
}
|
||||
}
|
||||
},
|
||||
SheetWriteResponse: {
|
||||
@@ -1832,6 +2047,34 @@ function googleCredentialHeaderParameters(): Record<string, unknown>[] {
|
||||
required: false,
|
||||
schema: { type: "string", example: "GOOGLE_API_KEY" },
|
||||
description: "Jmeno env promenne obsahujici Google API key. Hodnota env se nevraci ve vystupu."
|
||||
},
|
||||
{
|
||||
name: "X-Google-Service-Account-Json",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description: "Cely Google service account JSON jako string. Sluzba z nej vystavi access token automaticky. Secret se nevraci ve vystupu."
|
||||
},
|
||||
{
|
||||
name: "X-Google-Service-Account-Json-Env",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string", example: "GOOGLE_SERVICE_ACCOUNT_JSON" },
|
||||
description: "Jmeno env promenne obsahujici cely service account JSON."
|
||||
},
|
||||
{
|
||||
name: "X-Google-Service-Account-Scopes",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string", example: "https://www.googleapis.com/auth/spreadsheets" },
|
||||
description: "Scopes pro automaticke vystaveni tokenu ze service account JSON."
|
||||
},
|
||||
{
|
||||
name: "X-Google-Service-Account-Subject",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description: "Volitelny Workspace uzivatel pro domain-wide delegation."
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1896,6 +2139,13 @@ function serviceAccountHeaderParameters(): Record<string, unknown>[] {
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description: "Alternativa k body.scope/body.scopes nebo env GOOGLE_SCOPES."
|
||||
},
|
||||
{
|
||||
name: "X-Google-Service-Account-Json",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description: "Cely Google service account JSON jako string. Alternativa k body.serviceAccountJson nebo env GOOGLE_SERVICE_ACCOUNT_JSON."
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user