diff --git a/README.md b/README.md index 5273288..8850907 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Env promenne lze nahradit primo v requestu: - `GOOGLE_SERVICE_ACCOUNT_EMAIL`: `serviceAccountEmail` nebo `X-Google-Service-Account-Email` - `GOOGLE_PRIVATE_KEY`: `privateKey` nebo `X-Google-Private-Key` - `GOOGLE_SCOPES`: `scope`, `scopes` nebo `X-Google-Scopes` +- `GOOGLE_SERVICE_ACCOUNT_JSON`: `serviceAccountJson`, `serviceAccountJsonEnv`, `X-Google-Service-Account-Json`, `X-Google-Service-Account-Json-Env` - `GOOGLE_ACCESS_TOKEN`: `Authorization: Bearer `, `accessToken`, `accessTokenEnv`, `X-Google-Access-Token`, `X-Google-Access-Token-Env` - `GOOGLE_API_KEY`: `apiKey`, `apiKeyEnv`, `X-Google-Api-Key`, `X-Google-Api-Key-Env` @@ -143,6 +144,31 @@ POST /google/sheets/write-by-url `mode=append` prida radky pod existujici data. `mode=update` prepise bunky od `startCell`. +Stejny endpoint lze volat primo se service account JSON. V tom pripade se neposila `Authorization` header; sluzba si z `client_email` a `private_key` sama vystavi access token: + +```json +{ + "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1abcDEFghiJKLmnopQRstuVWXyz/edit#gid=0", + "sheetName": "Objednavky", + "mode": "append", + "values": [ + ["2026-06-15", "ACME", 1234] + ], + "serviceAccountScopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "serviceAccountJson": { + "type": "service_account", + "project_id": "project-id", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + "client_email": "service-account@project-id.iam.gserviceaccount.com", + "client_id": "123456789", + "token_uri": "https://oauth2.googleapis.com/token" + } +} +``` + ## OAuth postup 1. Zavolej `GET /google/oauth/scopes` a vyber scopes podle sluzeb. diff --git a/documentation/google-api-communication.md b/documentation/google-api-communication.md index 10b678e..dfe8463 100644 --- a/documentation/google-api-communication.md +++ b/documentation/google-api-communication.md @@ -36,9 +36,51 @@ Swagger obsahuje `GET /google/configuration`, ktery vypise podporovane env prome - `GOOGLE_SERVICE_ACCOUNT_EMAIL`: `serviceAccountEmail`, `X-Google-Service-Account-Email` - `GOOGLE_PRIVATE_KEY`: `privateKey`, `X-Google-Private-Key` - `GOOGLE_SCOPES`: `scope`, `scopes`, `X-Google-Scopes` +- `GOOGLE_SERVICE_ACCOUNT_JSON`: `serviceAccountJson`, `serviceAccountJsonEnv`, `X-Google-Service-Account-Json`, `X-Google-Service-Account-Json-Env` - `GOOGLE_ACCESS_TOKEN`: `Authorization: Bearer `, `accessToken`, `accessTokenEnv`, `X-Google-Access-Token`, `X-Google-Access-Token-Env` - `GOOGLE_API_KEY`: `apiKey`, `apiKeyEnv`, `X-Google-Api-Key`, `X-Google-Api-Key-Env` +### Service account JSON + +Google service account JSON neobsahuje `access_token`. Obsahuje hlavne `client_email`, `private_key` a `token_uri`. Sluzba z techto poli sama vytvori JWT assertion, zavola Google OAuth token endpoint a ziska docasny `access_token`. + +Produktove endpointy proto lze volat primo s: + +- `serviceAccountJson` v body +- `X-Google-Service-Account-Json` header +- `serviceAccountJsonEnv` nebo `X-Google-Service-Account-Json-Env`, napr. `GOOGLE_SERVICE_ACCOUNT_JSON` + +Je nutne dodat scopes: + +- `serviceAccountScopes` v body +- `X-Google-Service-Account-Scopes` header +- nebo `GOOGLE_SCOPES` + +Priklad pro zapis do Sheets: + +```json +{ + "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1abcDEFghiJKLmnopQRstuVWXyz/edit#gid=0", + "sheetName": "Objednavky", + "mode": "append", + "values": [ + ["2026-06-15", "ACME", 1234] + ], + "serviceAccountScopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "serviceAccountJson": { + "type": "service_account", + "project_id": "project-id", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + "client_email": "service-account@project-id.iam.gserviceaccount.com", + "client_id": "123456789", + "token_uri": "https://oauth2.googleapis.com/token" + } +} +``` + ### OAuth krok za krokem 1. Zavolej `GET /google/oauth/scopes`. diff --git a/src/index.ts b/src/index.ts index 4d5c7e5..d1b695b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = { 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 = {}; + + 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 { + 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 { + 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 = { + 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).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 { const contentType = response.headers.get("content-type") || "application/json"; const text = await response.text(); @@ -1438,6 +1620,8 @@ function buildOpenApiSchemas(): Record { 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 { 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 { example: [["Datum", "Castka"], ["2026-06-15", 1234]] }, accessToken: { type: "string", description: "Volitelne. Lepsi je poslat Authorization: Bearer ." }, - 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[] { 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[] { 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." } ]; }