From 715f80d365841a4ab07b639a5b22a04bf8851643 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Fri, 29 May 2026 20:05:35 +0300 Subject: [PATCH 1/9] add support for google service accounts --- .../googledrive/configuration-schema.json | 37 ++- packages/googledrive/src/Adaptor.js | 23 +- packages/googledrive/test/Adaptor.test.js | 36 +++ .../googlehealthcare/test/Adaptor.test.js | 49 ++++ packages/googlehealthcare/test/mockAgent.js | 20 ++ .../googlesheets/configuration-schema.json | 37 ++- packages/googlesheets/package.json | 4 +- packages/googlesheets/src/Adaptor.js | 23 +- packages/googlesheets/test/index.js | 251 ++++++++++++++---- 9 files changed, 405 insertions(+), 75 deletions(-) diff --git a/packages/googledrive/configuration-schema.json b/packages/googledrive/configuration-schema.json index 9f094b7d40..1baa73deb8 100644 --- a/packages/googledrive/configuration-schema.json +++ b/packages/googledrive/configuration-schema.json @@ -1,19 +1,46 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$comment": "OAuth2", + "type": "object", "properties": { "access_token": { "title": "Access Token", "type": "string", - "description": "Your Google Drive access token", + "description": "OAuth2 access token for Google Drive", "writeOnly": true, "minLength": 1, "examples": [ "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjlGWERwYmZNRlQyU3ZRdVhoODQ2WVR3RUlCdyIsI" ] + }, + "private_key": { + "title": "Private Key", + "type": "string", + "description": "RSA private key from the Google service account JSON key file", + "writeOnly": true, + "minLength": 1, + "examples": [ + "-----BEGIN RSA PRIVATE KEY-----\nMIIEow..." + ] + }, + "client_email": { + "title": "Client Email", + "type": "string", + "description": "Service account email address (e.g. my-app@project-id.iam.gserviceaccount.com)", + "minLength": 1, + "examples": [ + "my-app@project-id.iam.gserviceaccount.com" + ] } }, - "type": "object", - "additionalProperties": true, - "required": ["access_token"] + "oneOf": [ + { + "$comment": "OAuth2", + "required": ["access_token"] + }, + { + "$comment": "Service Account", + "required": ["private_key", "client_email"] + } + ], + "additionalProperties": true } diff --git a/packages/googledrive/src/Adaptor.js b/packages/googledrive/src/Adaptor.js index 19a326aad8..91b626f69e 100644 --- a/packages/googledrive/src/Adaptor.js +++ b/packages/googledrive/src/Adaptor.js @@ -26,9 +26,20 @@ let client; * @returns {Object} state with Google Drive client initialized. */ function createConnection(state) { - const { accessToken } = state.configuration; - const auth = new google.auth.OAuth2(); - auth.credentials = { access_token: accessToken }; + const { accessToken, private_key, client_email } = state.configuration; + + let auth; + if (private_key && client_email) { + auth = new google.auth.JWT({ + email: client_email, + key: private_key, + scopes: ['https://www.googleapis.com/auth/drive'], + }); + } else { + auth = new google.auth.OAuth2(); + auth.credentials = { access_token: accessToken }; + } + client = google.drive({ version: 'v3', auth }); return state; } @@ -63,6 +74,8 @@ export function execute(...operations) { }; return state => { + const isServiceAccount = + state.configuration?.private_key && state.configuration?.client_email; return commonExecute( createConnection, ...operations, @@ -70,7 +83,9 @@ export function execute(...operations) { )({ ...initialState, ...state, - configuration: normalizeOauthConfig(state.configuration), + configuration: isServiceAccount + ? state.configuration + : normalizeOauthConfig(state.configuration), }); }; } diff --git a/packages/googledrive/test/Adaptor.test.js b/packages/googledrive/test/Adaptor.test.js index 4db451f6d2..2b59891602 100644 --- a/packages/googledrive/test/Adaptor.test.js +++ b/packages/googledrive/test/Adaptor.test.js @@ -150,4 +150,40 @@ describe('Google Drive Adaptor', () => { } }); }); + + describe('service account auth', () => { + const serviceAccountState = { + configuration: { + private_key: + '-----BEGIN RSA PRIVATE KEY-----\nMOCK_KEY\n-----END RSA PRIVATE KEY-----', + client_email: 'service@project-id.iam.gserviceaccount.com', + }, + }; + + it('uses JWT auth when private_key and client_email are provided', async () => { + const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + + const content = Buffer.from('file content').toString('base64'); + await execute(create(content, 'test.txt'))(serviceAccountState); + + expect(jwtStub.calledOnce).to.be.true; + const jwtArgs = jwtStub.getCall(0).args[0]; + expect(jwtArgs.email).to.equal('service@project-id.iam.gserviceaccount.com'); + expect(jwtArgs.scopes).to.deep.equal([ + 'https://www.googleapis.com/auth/drive', + ]); + expect(mockFiles.create.calledOnce).to.be.true; + }); + + it('does not call OAuth2 when service account credentials are used', async () => { + const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + const oauth2Spy = sandbox.spy(google.auth, 'OAuth2'); + + const result = await execute(list('folder123'))(serviceAccountState); + + expect(jwtStub.calledOnce).to.be.true; + expect(oauth2Spy.called).to.be.false; + expect(result.data).to.be.an('array').with.lengthOf(2); + }); + }); }); diff --git a/packages/googlehealthcare/test/Adaptor.test.js b/packages/googlehealthcare/test/Adaptor.test.js index 6bb02df943..91e7719fa6 100644 --- a/packages/googlehealthcare/test/Adaptor.test.js +++ b/packages/googlehealthcare/test/Adaptor.test.js @@ -169,4 +169,53 @@ describe('createFhirResource', () => { 'Missing key(s) in fhirStore: cloudRegion, projectId, datasetId, fhirStoreId' ); }); + + it('throws an error listing only the missing fhirStore keys', async () => { + const state = { + configuration: { accessToken: 'aGVsbG86dGhlcmU=a' }, + }; + + const error = await execute( + createFhirResource( + { cloudRegion: 'us-east7', projectId: 'test-007' }, + { resourceType: 'Patient' } + ) + )(state).catch(e => e); + + expect(error.message).to.contains('Missing key(s) in fhirStore: datasetId, fhirStoreId'); + expect(error.message).to.not.contains('cloudRegion'); + expect(error.message).to.not.contains('projectId'); + }); + + it('accepts access_token (snake_case) and normalizes it to a Bearer token', async () => { + const state = { + configuration: { + access_token: 'snake-case-token', + }, + data: { + fhirStore: { + cloudRegion: 'us-east7', + projectId: 'test-007', + datasetId: 'fhir-007', + fhirStoreId: 'testing-fhir-007', + }, + resource: { + resourceType: 'Patient', + name: [{ use: 'official', family: 'Jones', given: ['Bob'] }], + gender: 'male', + birthDate: '1985-03-10', + }, + }, + }; + + const finalState = await execute( + createFhirResource( + state => state.data.fhirStore, + state => state.data.resource + ) + )(state); + + expect(finalState.data.data).to.have.property('resourceType', 'Patient'); + expect(finalState.data.data).to.have.property('id', 'abc-123'); + }); }); diff --git a/packages/googlehealthcare/test/mockAgent.js b/packages/googlehealthcare/test/mockAgent.js index de5a74e261..99bd156829 100644 --- a/packages/googlehealthcare/test/mockAgent.js +++ b/packages/googlehealthcare/test/mockAgent.js @@ -59,4 +59,24 @@ mockPool code: 401, }); +// Intercept for access_token (snake_case) test — normalizeOauthConfig converts it to the same token value +mockPool + .intercept({ + path: '/v1/projects/test-007/locations/us-east7/datasets/fhir-007/fhirStores/testing-fhir-007/fhir/Patient', + method: 'POST', + headers: { + 'content-type': 'application/fhir+json', + Authorization: 'Bearer snake-case-token', + }, + }) + .reply(200, { + data: { + id: 'abc-123', + resourceType: 'Patient', + name: [{ use: 'official', family: 'Jones', given: ['Bob'] }], + gender: 'male', + birthDate: '1985-03-10', + }, + }); + export default mockAgent; diff --git a/packages/googlesheets/configuration-schema.json b/packages/googlesheets/configuration-schema.json index 273aec3c87..41a465ae13 100644 --- a/packages/googlesheets/configuration-schema.json +++ b/packages/googlesheets/configuration-schema.json @@ -1,19 +1,46 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$comment": "OAuth2", + "type": "object", "properties": { "access_token": { "title": "Access Token", "type": "string", - "description": "Your Google Sheets access token", + "description": "OAuth2 access token for Google Sheets", "writeOnly": true, "minLength": 1, "examples": [ "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjlGWERwYmZNRlQyU3ZRdVhoODQ2WVR3RUlCdyIsI" ] + }, + "private_key": { + "title": "Private Key", + "type": "string", + "description": "RSA private key from the Google service account JSON key file", + "writeOnly": true, + "minLength": 1, + "examples": [ + "-----BEGIN RSA PRIVATE KEY-----\nMIIEow..." + ] + }, + "client_email": { + "title": "Client Email", + "type": "string", + "description": "Service account email address (e.g. my-app@project-id.iam.gserviceaccount.com)", + "minLength": 1, + "examples": [ + "my-app@project-id.iam.gserviceaccount.com" + ] } }, - "type": "object", - "additionalProperties": true, - "required": ["access_token"] + "oneOf": [ + { + "$comment": "OAuth2", + "required": ["access_token"] + }, + { + "$comment": "Service Account", + "required": ["private_key", "client_email"] + } + ], + "additionalProperties": true } diff --git a/packages/googlesheets/package.json b/packages/googlesheets/package.json index cf621f9f5d..dfc7755b09 100644 --- a/packages/googlesheets/package.json +++ b/packages/googlesheets/package.json @@ -40,8 +40,10 @@ "assertion-error": "1.1.0", "chai": "4.3.6", "deep-eql": "4.1.1", + "mocha": "^10.7.3", "nock": "13.2.9", - "rimraf": "3.0.2" + "rimraf": "3.0.2", + "sinon": "^19.0.4" }, "type": "module", "types": "types/index.d.ts", diff --git a/packages/googlesheets/src/Adaptor.js b/packages/googlesheets/src/Adaptor.js index 5efe55924f..315a118af6 100644 --- a/packages/googlesheets/src/Adaptor.js +++ b/packages/googlesheets/src/Adaptor.js @@ -12,10 +12,19 @@ import { google } from 'googleapis'; let client = undefined; function createConnection(state) { - const { accessToken } = state.configuration; + const { accessToken, private_key, client_email } = state.configuration; - const auth = new google.auth.OAuth2(); - auth.credentials = { access_token: accessToken }; + let auth; + if (private_key && client_email) { + auth = new google.auth.JWT({ + email: client_email, + key: private_key, + scopes: ['https://www.googleapis.com/auth/spreadsheets'], + }); + } else { + auth = new google.auth.OAuth2(); + auth.credentials = { access_token: accessToken }; + } client = google.sheets({ version: 'v4', auth }); return state; @@ -59,8 +68,8 @@ export function execute(...operations) { // why not here? return state => { - // Note: we no longer need `steps` anymore since `commonExecute` - // takes each operation as an argument. + const isServiceAccount = + state.configuration?.private_key && state.configuration?.client_email; return commonExecute( createConnection, ...operations, @@ -68,7 +77,9 @@ export function execute(...operations) { )({ ...initialState, ...state, - configuration: normalizeOauthConfig(state.configuration), + configuration: isServiceAccount + ? state.configuration + : normalizeOauthConfig(state.configuration), }); }; } diff --git a/packages/googlesheets/test/index.js b/packages/googlesheets/test/index.js index 18a84935a7..3f6bf6bf47 100644 --- a/packages/googlesheets/test/index.js +++ b/packages/googlesheets/test/index.js @@ -1,70 +1,213 @@ import { expect } from 'chai'; +import sinon from 'sinon'; +import { google } from 'googleapis'; +import { + execute, + appendValues, + batchUpdateValues, + getValues, +} from '../src/index.js'; -import { execute ,appendValues, batchUpdateValues } from '../src/index.js'; +describe('Google Sheets Adaptor', () => { + let sandbox; + let mockSheets; + let mockValues; + beforeEach(() => { + sandbox = sinon.createSandbox(); -describe('execute', () => { - it('executes each operation in sequence', done => { - const state = { configuration: {}, data: {} }; - let operations = [ - state => { - return { counter: 1 }; - }, - state => { - return { counter: 2 }; - }, - state => { - return { counter: 3 }; - }, - ]; - - execute(...operations)(state) - .then(finalState => { - expect(finalState).to.eql({ counter: 3 }); - }) - .then(done) - .catch(done); + mockValues = { + append: sandbox.stub().callsFake((params, callback) => { + callback(null, { data: { updates: { updatedCells: 5 } } }); + }), + batchUpdate: sandbox.stub().resolves({ data: { totalUpdatedCells: 5 } }), + get: sandbox.stub().resolves({ + data: { values: [['Name', 'Score'], ['Alice', '42']] }, + }), + }; + mockSheets = { spreadsheets: { values: mockValues } }; + sandbox.stub(google, 'sheets').returns(mockSheets); }); - it('assigns references, data to the initialState', () => { - const state = { configuration: {}, data: {} }; + afterEach(() => { + sandbox.restore(); + }); + + describe('execute()', () => { + it('executes each operation in sequence', async () => { + const state = { configuration: { access_token: 'mockToken' }, data: {} }; + const operations = [ + state => ({ ...state, counter: 1 }), + state => ({ ...state, counter: 2 }), + state => ({ ...state, counter: 3 }), + ]; + + const result = await execute(...operations)(state); + expect(result.counter).to.equal(3); + }); + + it('seeds references and data defaults when not present in state', async () => { + const state = { configuration: { access_token: 'mockToken' } }; - execute()(state).then(finalState => { - expect(finalState).to.eql({ - references: [], - data: null, - }); + const result = await execute()(state); + expect(result.references).to.eql([]); + expect(result.data).to.be.null; }); }); -}); + describe('appendValues()', () => { + it('appends rows to a spreadsheet', async () => { + const state = { configuration: { access_token: 'mockToken' } }; + + await execute( + appendValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [['Alice', '42']], + }) + )(state); + + expect(mockValues.append.calledOnce).to.be.true; + const args = mockValues.append.getCall(0).args[0]; + expect(args.spreadsheetId).to.equal('sheet123'); + expect(args.range).to.equal('Sheet1!A1:B1'); + }); -describe('append', () =>{ - it('should return early if the values array is undefined or nullish', async() => { - const state = { - data: [], - } + it('returns early without calling the API when values is empty', async () => { + const state = { configuration: { access_token: 'mockToken' } }; - const result = await appendValues({ - spreadsheetId: '123-456-789', - range: 'Sheet!A1:E1', - values: state.values, - })(state); - expect(result).to.eql(state); + await execute( + appendValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [], + }) + )(state); + + expect(mockValues.append.called).to.be.false; + }); + + it('returns early without calling the API when values is undefined', async () => { + const state = { configuration: { access_token: 'mockToken' }, data: [] }; + + const result = await appendValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: state.values, + })(state); + + expect(result).to.eql(state); + expect(mockValues.append.called).to.be.false; + }); }); -}); -describe('batchUpdateValues', () =>{ - it('should return early if the values array is undefined or nullish', async() => { - const state = { - data: [], - } - - const result = await batchUpdateValues({ - spreadsheetId: '123-456-789', - range: 'Sheet!A1:E1', - values: state.values, - })(state); - expect(result).to.eql(state); + describe('batchUpdateValues()', () => { + it('batch updates cells in a spreadsheet', async () => { + const state = { configuration: { access_token: 'mockToken' } }; + + const result = await execute( + batchUpdateValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [['Alice', '42']], + }) + )(state); + + expect(mockValues.batchUpdate.calledOnce).to.be.true; + const args = mockValues.batchUpdate.getCall(0).args[0]; + expect(args.spreadsheetId).to.equal('sheet123'); + expect(result.data.totalUpdatedCells).to.equal(5); + }); + + it('defaults valueInputOption to USER_ENTERED', async () => { + const state = { configuration: { access_token: 'mockToken' } }; + + await execute( + batchUpdateValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [['Alice', '42']], + }) + )(state); + + const args = mockValues.batchUpdate.getCall(0).args[0]; + expect(args.resource.valueInputOption).to.equal('USER_ENTERED'); + }); + + it('returns early without calling the API when values is empty', async () => { + const state = { configuration: { access_token: 'mockToken' } }; + + await execute( + batchUpdateValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [], + }) + )(state); + + expect(mockValues.batchUpdate.called).to.be.false; + }); + }); + + describe('getValues()', () => { + it('retrieves cell values from a spreadsheet', async () => { + const state = { configuration: { access_token: 'mockToken' } }; + + const result = await execute(getValues('sheet123', 'Sheet1!A1:B2'))(state); + + expect(mockValues.get.calledOnce).to.be.true; + const args = mockValues.get.getCall(0).args[0]; + expect(args.spreadsheetId).to.equal('sheet123'); + expect(args.range).to.equal('Sheet1!A1:B2'); + expect(result.data.values).to.deep.equal([ + ['Name', 'Score'], + ['Alice', '42'], + ]); + }); + }); + + describe('service account auth', () => { + const serviceAccountState = { + configuration: { + private_key: + '-----BEGIN RSA PRIVATE KEY-----\nMOCK_KEY\n-----END RSA PRIVATE KEY-----', + client_email: 'service@project-id.iam.gserviceaccount.com', + }, + }; + + it('uses JWT auth when private_key and client_email are provided', async () => { + const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + + await execute( + appendValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [['Alice', '42']], + }) + )(serviceAccountState); + + expect(jwtStub.calledOnce).to.be.true; + const jwtArgs = jwtStub.getCall(0).args[0]; + expect(jwtArgs.email).to.equal('service@project-id.iam.gserviceaccount.com'); + expect(jwtArgs.scopes).to.deep.equal([ + 'https://www.googleapis.com/auth/spreadsheets', + ]); + }); + + it('does not call OAuth2 when service account credentials are used', async () => { + const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + const oauth2Spy = sandbox.spy(google.auth, 'OAuth2'); + + await execute( + appendValues({ + spreadsheetId: 'sheet123', + range: 'Sheet1!A1:B1', + values: [['Alice', '42']], + }) + )(serviceAccountState); + + expect(jwtStub.calledOnce).to.be.true; + expect(oauth2Spy.called).to.be.false; + }); }); }); From 4c09707ba054fa532e7e662af66845d1659634e7 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Mon, 1 Jun 2026 10:38:16 +0300 Subject: [PATCH 2/9] support service accounts credentials --- packages/gmail/configuration-schema.json | 45 ++++++++++-- packages/gmail/package.json | 1 + packages/gmail/src/Adaptor.js | 7 +- packages/gmail/src/Utils.js | 49 ++++++++----- packages/gmail/test/Adaptor.test.js | 4 +- packages/gmail/test/utils.test.js | 92 +++++++++++++++++++++++- 6 files changed, 171 insertions(+), 27 deletions(-) diff --git a/packages/gmail/configuration-schema.json b/packages/gmail/configuration-schema.json index 02f1378abc..482576d097 100644 --- a/packages/gmail/configuration-schema.json +++ b/packages/gmail/configuration-schema.json @@ -1,19 +1,54 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$comment": "OAuth2", + "type": "object", "properties": { "access_token": { "title": "Access Token", "type": "string", - "description": "Your Gmail access token", + "description": "Your Gmail OAuth2 access token", "writeOnly": true, "minLength": 1, "examples": [ "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IjlGWERwYmhax0rZNRlQyU3ZRdVhoODQ2WVR3RUlCdyIsI" ] + }, + "private_key": { + "title": "Private Key", + "type": "string", + "description": "RSA private key from the Google service account JSON key file", + "writeOnly": true, + "minLength": 1, + "examples": [ + "-----BEGIN RSA PRIVATE KEY-----\nMIIEow..." + ] + }, + "client_email": { + "title": "Client Email", + "type": "string", + "description": "Service account email address (e.g. my-app@project-id.iam.gserviceaccount.com)", + "minLength": 1, + "examples": [ + "my-app@project-id.iam.gserviceaccount.com" + ] + }, + "subject": { + "title": "Subject Email", + "type": "string", + "description": "Gmail user to impersonate via domain-wide delegation (e.g. user@yourdomain.com). Required for service account access to Gmail.", + "examples": [ + "user@yourdomain.com" + ] } }, - "type": "object", - "additionalProperties": true, - "required": ["access_token"] + "oneOf": [ + { + "$comment": "OAuth2", + "required": ["access_token"] + }, + { + "$comment": "Service Account", + "required": ["private_key", "client_email"] + } + ], + "additionalProperties": true } diff --git a/packages/gmail/package.json b/packages/gmail/package.json index c4dc42be40..267bf57d73 100644 --- a/packages/gmail/package.json +++ b/packages/gmail/package.json @@ -40,6 +40,7 @@ "deep-eql": "4.1.1", "mocha": "^10.8.2", "rimraf": "3.0.2", + "sinon": "^19.0.4", "undici": "^7.24.7" }, "repository": { diff --git a/packages/gmail/src/Adaptor.js b/packages/gmail/src/Adaptor.js index 2520579af5..11c782ffff 100644 --- a/packages/gmail/src/Adaptor.js +++ b/packages/gmail/src/Adaptor.js @@ -216,6 +216,9 @@ export function execute(...operations) { }; return state => { + const isServiceAccount = + state.configuration?.private_key && state.configuration?.client_email; + return commonExecute( createConnection, ...operations, @@ -223,7 +226,9 @@ export function execute(...operations) { )({ ...initialState, ...state, - configuration: normalizeOauthConfig(state.configuration), + configuration: isServiceAccount + ? state.configuration + : normalizeOauthConfig(state.configuration), }); }; } diff --git a/packages/gmail/src/Utils.js b/packages/gmail/src/Utils.js index 11a7f0ecb7..199ec18933 100644 --- a/packages/gmail/src/Utils.js +++ b/packages/gmail/src/Utils.js @@ -40,7 +40,7 @@ export async function getMessageResult(userId, messageId) { export function getContentIndicators( defaultContentRequests = [], - contentRequests = [] + contentRequests = [], ) { const contentIndicators = contentRequests.map(getContentIndicator); const contentNames = new Set(contentIndicators.map(({ name }) => name)); @@ -68,7 +68,7 @@ function getContentIndicator(contentRequest) { if (!contentIndicator.type) { console.error( - `Unable to determine desired content type: ${contentRequest}` + `Unable to determine desired content type: ${contentRequest}`, ); throw new Error('No desired content type provided.'); } @@ -127,7 +127,7 @@ export async function buildAndSendMessage(message) { 'Content-Transfer-Encoding: base64', `Content-Disposition: attachment; filename="${file}"`, '', - attachment.content + attachment.content, ); } @@ -183,11 +183,26 @@ async function parseArchiveAttachment(attachment) { }; } -export function createConnection(state) { - const { access_token } = state.configuration; - - const auth = new google.auth.OAuth2(); - auth.credentials = { access_token }; +export async function createConnection(state) { + const { access_token, private_key, client_email, subject } = + state.configuration; + + let auth; + if (private_key && client_email) { + auth = new google.auth.JWT({ + email: client_email, + key: private_key, + scopes: [ + 'https://mail.google.com/', + 'https://www.googleapis.com/auth/gmail.readonly', + ], + subject, + }); + await auth.authorize(); + } else { + auth = new google.auth.OAuth2(); + auth.credentials = { access_token }; + } gmail = google.gmail({ version: 'v1', auth }); @@ -202,19 +217,19 @@ export function removeConnection(state) { async function getFileFromArchiveFromAttachment(message, desiredContent) { const attachmentResult = await getAttachmentResult( message, - desiredContent.archive + desiredContent.archive, ); return await extractFileFromArchiveAttachment( attachmentResult, - desiredContent + desiredContent, ); } async function getFileFromAttachment(message, desiredContent) { const attachmentResult = await getAttachmentResult( message, - desiredContent.file + desiredContent.file, ); return await extractFileFromAttachment(attachmentResult, desiredContent); @@ -250,7 +265,7 @@ async function extractFileFromArchiveAttachment(attachment, desiredContent) { if (!attachment.data) { console.error( - `Data not found in the archive attachment for: ${attachment.expression}` + `Data not found in the archive attachment for: ${attachment.expression}`, ); return null; } @@ -259,7 +274,7 @@ async function extractFileFromArchiveAttachment(attachment, desiredContent) { const zip = await JSZip.loadAsync(compressedBuffer); const filename = Object.keys(zip.files).find(name => - isExpressionMatch(name, desiredContent.file) + isExpressionMatch(name, desiredContent.file), ); if (!filename) { @@ -286,7 +301,7 @@ async function extractFileFromAttachment(attachment, desiredContent) { if (!attachment.data) { console.error( - `Data not found in the file attachment for: ${attachment.expression}` + `Data not found in the file attachment for: ${attachment.expression}`, ); return null; } @@ -303,11 +318,11 @@ async function extractFileFromAttachment(attachment, desiredContent) { function getBodyFromMessage(message, desiredContent) { const bodyPart = message.parts?.find( - part => part.mimeType === 'multipart/alternative' + part => part.mimeType === 'multipart/alternative', ); const textBodyPart = bodyPart?.parts.find( - part => part.mimeType === 'text/plain' + part => part.mimeType === 'text/plain', ); const textBody = textBodyPart?.body?.data; @@ -324,7 +339,7 @@ function getBodyFromMessage(message, desiredContent) { function getValueFromMessageHeader(message, desiredContent) { const header = message.headers?.find( - h => h.name.toLowerCase() === desiredContent.type + h => h.name.toLowerCase() === desiredContent.type, ); if (!header) { diff --git a/packages/gmail/test/Adaptor.test.js b/packages/gmail/test/Adaptor.test.js index eb9fb1cff9..5d91819b05 100644 --- a/packages/gmail/test/Adaptor.test.js +++ b/packages/gmail/test/Adaptor.test.js @@ -13,7 +13,7 @@ describe('sendMessage', () => { let mockGmail; let sendStub; - beforeEach(() => { + beforeEach(async () => { originalGmail = google.gmail; const mockResponse = { @@ -36,7 +36,7 @@ describe('sendMessage', () => { google.gmail = () => mockGmail; - createConnection({ + await createConnection({ configuration: { access_token: 'mock-access-token', }, diff --git a/packages/gmail/test/utils.test.js b/packages/gmail/test/utils.test.js index 20016e17ff..98b6f5f4e4 100644 --- a/packages/gmail/test/utils.test.js +++ b/packages/gmail/test/utils.test.js @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import sinon from 'sinon'; import { google } from 'googleapis'; import { buildAndSendMessage, @@ -12,7 +13,7 @@ describe('buildAndSendMessage', () => { let mockGmail; let sendStub; - beforeEach(() => { + beforeEach(async () => { originalGmail = google.gmail; const mockResponse = { @@ -35,7 +36,7 @@ describe('buildAndSendMessage', () => { google.gmail = () => mockGmail; - createConnection({ + await createConnection({ configuration: { access_token: 'mock-access-token', }, @@ -247,3 +248,90 @@ describe('buildAndSendMessage', () => { }); }); }); + +describe('createConnection', () => { + let sandbox; + let originalGmail; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + originalGmail = google.gmail; + google.gmail = () => ({}); + }); + + afterEach(() => { + google.gmail = originalGmail; + sandbox.restore(); + removeConnection(); + }); + + it('uses OAuth2 when access_token is provided', async () => { + const oauth2Spy = sandbox.spy(google.auth, 'OAuth2'); + + await createConnection({ configuration: { access_token: 'mock-token' } }); + + expect(oauth2Spy.calledOnce).to.be.true; + }); + + it('uses JWT auth when private_key and client_email are provided', async () => { + const mockJwt = { authorize: sandbox.stub().resolves() }; + const jwtStub = sandbox.stub(google.auth, 'JWT').returns(mockJwt); + + await createConnection({ + configuration: { + private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + client_email: 'service@project-id.iam.gserviceaccount.com', + }, + }); + + expect(jwtStub.calledOnce).to.be.true; + const jwtArgs = jwtStub.getCall(0).args[0]; + expect(jwtArgs.email).to.equal('service@project-id.iam.gserviceaccount.com'); + expect(jwtArgs.scopes).to.deep.equal(['https://mail.google.com/']); + }); + + it('calls authorize() to fetch an initial access token for JWT auth', async () => { + const mockJwt = { authorize: sandbox.stub().resolves() }; + sandbox.stub(google.auth, 'JWT').returns(mockJwt); + + await createConnection({ + configuration: { + private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + client_email: 'service@project-id.iam.gserviceaccount.com', + }, + }); + + expect(mockJwt.authorize.calledOnce).to.be.true; + }); + + it('passes subject to JWT when provided for domain-wide delegation', async () => { + const mockJwt = { authorize: sandbox.stub().resolves() }; + const jwtStub = sandbox.stub(google.auth, 'JWT').returns(mockJwt); + + await createConnection({ + configuration: { + private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + client_email: 'service@project-id.iam.gserviceaccount.com', + subject: 'user@yourdomain.com', + }, + }); + + const jwtArgs = jwtStub.getCall(0).args[0]; + expect(jwtArgs.subject).to.equal('user@yourdomain.com'); + }); + + it('does not call OAuth2 when service account credentials are used', async () => { + const mockJwt = { authorize: sandbox.stub().resolves() }; + sandbox.stub(google.auth, 'JWT').returns(mockJwt); + const oauth2Spy = sandbox.spy(google.auth, 'OAuth2'); + + await createConnection({ + configuration: { + private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + client_email: 'service@project-id.iam.gserviceaccount.com', + }, + }); + + expect(oauth2Spy.called).to.be.false; + }); +}); From 881b568b1d87fcaa6150abff0b066dab950d1c38 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Mon, 1 Jun 2026 10:38:41 +0300 Subject: [PATCH 3/9] turn createConnection into async function --- packages/googledrive/src/Adaptor.js | 3 ++- packages/googlesheets/src/Adaptor.js | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/googledrive/src/Adaptor.js b/packages/googledrive/src/Adaptor.js index 91b626f69e..9f56b0549f 100644 --- a/packages/googledrive/src/Adaptor.js +++ b/packages/googledrive/src/Adaptor.js @@ -25,7 +25,7 @@ let client; * @param {Object} state - object containing the access token. * @returns {Object} state with Google Drive client initialized. */ -function createConnection(state) { +async function createConnection(state) { const { accessToken, private_key, client_email } = state.configuration; let auth; @@ -35,6 +35,7 @@ function createConnection(state) { key: private_key, scopes: ['https://www.googleapis.com/auth/drive'], }); + await auth.authorize(); } else { auth = new google.auth.OAuth2(); auth.credentials = { access_token: accessToken }; diff --git a/packages/googlesheets/src/Adaptor.js b/packages/googlesheets/src/Adaptor.js index 315a118af6..ebbbce50f6 100644 --- a/packages/googlesheets/src/Adaptor.js +++ b/packages/googlesheets/src/Adaptor.js @@ -11,7 +11,7 @@ import { google } from 'googleapis'; let client = undefined; -function createConnection(state) { +async function createConnection(state) { const { accessToken, private_key, client_email } = state.configuration; let auth; @@ -21,6 +21,7 @@ function createConnection(state) { key: private_key, scopes: ['https://www.googleapis.com/auth/spreadsheets'], }); + await auth.authorize(); } else { auth = new google.auth.OAuth2(); auth.credentials = { access_token: accessToken }; @@ -70,10 +71,11 @@ export function execute(...operations) { return state => { const isServiceAccount = state.configuration?.private_key && state.configuration?.client_email; + return commonExecute( createConnection, ...operations, - removeConnection + removeConnection, )({ ...initialState, ...state, @@ -138,10 +140,10 @@ export function appendValues(params, callback = s => s) { callback({ ...composeNextState(state, response.data), response, - }) + }), ); } - } + }, ); }); }; @@ -223,7 +225,7 @@ export function getValues(spreadsheetId, range, callback = s => s) { const [resolvedSheetId, resolvedRange] = expandReferences( state, spreadsheetId, - range + range, ); try { From 5e61a5e9ac0ff6dfc3afd2dc5ada98dbc440d633 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Mon, 1 Jun 2026 10:39:12 +0300 Subject: [PATCH 4/9] devDependecy update for unit test --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bccb1e7c2..cc9b632b89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1025,6 +1025,9 @@ importers: rimraf: specifier: 3.0.2 version: 3.0.2 + sinon: + specifier: ^19.0.4 + version: 19.0.4 undici: specifier: ^7.24.7 version: 7.24.7 @@ -1128,12 +1131,18 @@ importers: deep-eql: specifier: 4.1.1 version: 4.1.1 + mocha: + specifier: ^10.7.3 + version: 10.8.2 nock: specifier: 13.2.9 version: 13.2.9 rimraf: specifier: 3.0.2 version: 3.0.2 + sinon: + specifier: ^19.0.4 + version: 19.0.4 packages/hive: dependencies: From c3da180441873f9dca5eb7820ad6df4b5cadc3c1 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Mon, 1 Jun 2026 11:14:22 +0300 Subject: [PATCH 5/9] update unit tests --- packages/gmail/src/Utils.js | 22 +++++++++++++++------- packages/gmail/test/utils.test.js | 23 +++++++++++++++++------ packages/googledrive/src/Adaptor.js | 16 ++++++++++++++-- packages/googledrive/test/Adaptor.test.js | 16 ++++++++++++---- packages/googlesheets/src/Adaptor.js | 15 +++++++++++++-- packages/googlesheets/test/index.js | 9 +++++++-- 6 files changed, 78 insertions(+), 23 deletions(-) diff --git a/packages/gmail/src/Utils.js b/packages/gmail/src/Utils.js index 199ec18933..648327b9da 100644 --- a/packages/gmail/src/Utils.js +++ b/packages/gmail/src/Utils.js @@ -184,18 +184,26 @@ async function parseArchiveAttachment(attachment) { } export async function createConnection(state) { - const { access_token, private_key, client_email, subject } = - state.configuration; - + const { + access_token, + private_key, + client_email, + subject, + scopes = [], + } = state.configuration; + + const mandatoryScopes = [ + 'https://mail.google.com/', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', + ]; let auth; if (private_key && client_email) { auth = new google.auth.JWT({ email: client_email, key: private_key, - scopes: [ - 'https://mail.google.com/', - 'https://www.googleapis.com/auth/gmail.readonly', - ], + scopes: [...mandatoryScopes, ...scopes], subject, }); await auth.authorize(); diff --git a/packages/gmail/test/utils.test.js b/packages/gmail/test/utils.test.js index 98b6f5f4e4..d7c5ac9ca2 100644 --- a/packages/gmail/test/utils.test.js +++ b/packages/gmail/test/utils.test.js @@ -279,15 +279,23 @@ describe('createConnection', () => { await createConnection({ configuration: { - private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + private_key: + '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', client_email: 'service@project-id.iam.gserviceaccount.com', }, }); expect(jwtStub.calledOnce).to.be.true; const jwtArgs = jwtStub.getCall(0).args[0]; - expect(jwtArgs.email).to.equal('service@project-id.iam.gserviceaccount.com'); - expect(jwtArgs.scopes).to.deep.equal(['https://mail.google.com/']); + expect(jwtArgs.email).to.equal( + 'service@project-id.iam.gserviceaccount.com', + ); + expect(jwtArgs.scopes).to.deep.equal([ + 'https://mail.google.com/', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', + ]); }); it('calls authorize() to fetch an initial access token for JWT auth', async () => { @@ -296,7 +304,8 @@ describe('createConnection', () => { await createConnection({ configuration: { - private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + private_key: + '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', client_email: 'service@project-id.iam.gserviceaccount.com', }, }); @@ -310,7 +319,8 @@ describe('createConnection', () => { await createConnection({ configuration: { - private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + private_key: + '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', client_email: 'service@project-id.iam.gserviceaccount.com', subject: 'user@yourdomain.com', }, @@ -327,7 +337,8 @@ describe('createConnection', () => { await createConnection({ configuration: { - private_key: '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', + private_key: + '-----BEGIN RSA PRIVATE KEY-----\nMOCK\n-----END RSA PRIVATE KEY-----', client_email: 'service@project-id.iam.gserviceaccount.com', }, }); diff --git a/packages/googledrive/src/Adaptor.js b/packages/googledrive/src/Adaptor.js index 9f56b0549f..d565ef6119 100644 --- a/packages/googledrive/src/Adaptor.js +++ b/packages/googledrive/src/Adaptor.js @@ -26,14 +26,26 @@ let client; * @returns {Object} state with Google Drive client initialized. */ async function createConnection(state) { - const { accessToken, private_key, client_email } = state.configuration; + const { + accessToken, + private_key, + client_email, + scopes = [], + } = state.configuration; + const mandatoryScopes = [ + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', + ]; let auth; if (private_key && client_email) { auth = new google.auth.JWT({ email: client_email, key: private_key, - scopes: ['https://www.googleapis.com/auth/drive'], + scopes: [...mandatoryScopes, ...scopes], }); await auth.authorize(); } else { diff --git a/packages/googledrive/test/Adaptor.test.js b/packages/googledrive/test/Adaptor.test.js index 2b59891602..6dbcda6ae3 100644 --- a/packages/googledrive/test/Adaptor.test.js +++ b/packages/googledrive/test/Adaptor.test.js @@ -161,22 +161,30 @@ describe('Google Drive Adaptor', () => { }; it('uses JWT auth when private_key and client_email are provided', async () => { - const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + const mockJwt = { authorize: sandbox.stub().resolves() }; + const jwtStub = sandbox.stub(google.auth, 'JWT').returns(mockJwt); const content = Buffer.from('file content').toString('base64'); await execute(create(content, 'test.txt'))(serviceAccountState); expect(jwtStub.calledOnce).to.be.true; const jwtArgs = jwtStub.getCall(0).args[0]; - expect(jwtArgs.email).to.equal('service@project-id.iam.gserviceaccount.com'); + expect(jwtArgs.email).to.equal( + 'service@project-id.iam.gserviceaccount.com', + ); expect(jwtArgs.scopes).to.deep.equal([ - 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', ]); expect(mockFiles.create.calledOnce).to.be.true; }); it('does not call OAuth2 when service account credentials are used', async () => { - const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + const mockJwt = { authorize: sandbox.stub().resolves() }; + const jwtStub = sandbox.stub(google.auth, 'JWT').returns(mockJwt); const oauth2Spy = sandbox.spy(google.auth, 'OAuth2'); const result = await execute(list('folder123'))(serviceAccountState); diff --git a/packages/googlesheets/src/Adaptor.js b/packages/googlesheets/src/Adaptor.js index ebbbce50f6..deadba65c4 100644 --- a/packages/googlesheets/src/Adaptor.js +++ b/packages/googlesheets/src/Adaptor.js @@ -12,14 +12,25 @@ import { google } from 'googleapis'; let client = undefined; async function createConnection(state) { - const { accessToken, private_key, client_email } = state.configuration; + const { + accessToken, + private_key, + client_email, + scopes = [], + } = state.configuration; + const mandatoryScopes = [ + 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', + ]; let auth; if (private_key && client_email) { auth = new google.auth.JWT({ email: client_email, key: private_key, - scopes: ['https://www.googleapis.com/auth/spreadsheets'], + scopes: [...mandatoryScopes, ...scopes], }); await auth.authorize(); } else { diff --git a/packages/googlesheets/test/index.js b/packages/googlesheets/test/index.js index 3f6bf6bf47..1dcdc25c7d 100644 --- a/packages/googlesheets/test/index.js +++ b/packages/googlesheets/test/index.js @@ -176,7 +176,8 @@ describe('Google Sheets Adaptor', () => { }; it('uses JWT auth when private_key and client_email are provided', async () => { - const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + const mockJwt = { authorize: sandbox.stub().resolves() }; + const jwtStub = sandbox.stub(google.auth, 'JWT').returns(mockJwt); await execute( appendValues({ @@ -191,11 +192,15 @@ describe('Google Sheets Adaptor', () => { expect(jwtArgs.email).to.equal('service@project-id.iam.gserviceaccount.com'); expect(jwtArgs.scopes).to.deep.equal([ 'https://www.googleapis.com/auth/spreadsheets', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'openid', ]); }); it('does not call OAuth2 when service account credentials are used', async () => { - const jwtStub = sandbox.stub(google.auth, 'JWT').returns({}); + const mockJwt = { authorize: sandbox.stub().resolves() }; + const jwtStub = sandbox.stub(google.auth, 'JWT').returns(mockJwt); const oauth2Spy = sandbox.spy(google.auth, 'OAuth2'); await execute( From 679007a89d36878422c871ef8f7efeef5c566c35 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Tue, 16 Jun 2026 22:34:09 +0300 Subject: [PATCH 6/9] document service account auth in gmail, googledrive, and googlesheets READMEs Co-Authored-By: Claude Sonnet 4.6 --- packages/gmail/README.md | 31 +++++++++++++++++++++--- packages/googledrive/README.md | 43 +++++++++++++++++++++++++++++++++ packages/googlesheets/README.md | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/packages/gmail/README.md b/packages/gmail/README.md index ea28d53af0..3682a9d4ac 100644 --- a/packages/gmail/README.md +++ b/packages/gmail/README.md @@ -250,13 +250,17 @@ sendMessage({ This will send an email with two plain attachments and one ZIP archive containing two files. -# Acquiring an access token +# Authentication + +This adaptor supports two authentication methods: **OAuth2** and **Service Account**. + +## OAuth2 The Gmail adaptor implicitly uses the Gmail account of the Google account that is used to authenticate the application. Allowing the Gmail adaptor to access a Gmail account is a multi-step process. -## Create an OAuth 2.0 client ID +### Create an OAuth 2.0 client ID Follow the instructions are found here: https://support.google.com/googleapi/answer/6158849 @@ -272,7 +276,7 @@ https://support.google.com/googleapi/answer/6158849 - Click "Create" - On the resulting popup screen, find and click "DOWNLOAD JSON" and save this file to a secure location. -## Retrieve an access token +### Retrieve an access token - Navigate to [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/). - Find *Step 1 Select & authorize APIs*: @@ -295,7 +299,7 @@ https://support.google.com/googleapi/answer/6158849 - *Refresh token* and *Access token* will be populated briefly before the interface automatically advances to *Step 3 Configure request to API*. To view the *Access token*, return to *Step 2 Exchange authorization code for tokens*. - The *Access token* is valid for 1 hour. You may enable **Auto-refresh the token before it expires** or manually refresh it using the **Refresh access token** button. -## Configure OpenFn CLI to find the access token +### Configure OpenFn CLI to find the access token The Gmail adaptor looks for the access token in the configuration section under `access_token`. @@ -317,3 +321,22 @@ Example configuration using a workflow: ] } ``` + +## Service Account + +Service accounts allow the adaptor to access Gmail without user interaction, +making them well-suited for automated workflows. + +> **Important:** Gmail service account access requires **Google Workspace**. +> It does **not** work with personal Gmail accounts (`@gmail.com`). If your +> organisation uses personal Google accounts, use the OAuth2 method instead. + +To create a service account and JSON key file, follow the +[Google Cloud service account documentation](https://cloud.google.com/iam/docs/service-accounts-create). +Your OpenFn credential requires the `client_email`, `private_key`, and `subject` +fields. The `subject` must be the email address of the Workspace user whose +mailbox the adaptor will access -- it cannot be the service account email itself. + +For enabling domain-wide delegation and authorising the required Gmail API +scopes, see the +[Google Workspace domain-wide delegation guide](https://support.google.com/a/answer/162106). diff --git a/packages/googledrive/README.md b/packages/googledrive/README.md index d598622cbb..ffe056a246 100644 --- a/packages/googledrive/README.md +++ b/packages/googledrive/README.md @@ -15,6 +15,49 @@ View the [configuration-schema](https://docs.openfn.org/adaptors/packages/googledrive-configuration-schema/) for required and optional `configuration` properties. +This adaptor supports two authentication methods: **OAuth2** and **Service Account**. + +#### OAuth2 + +Provide an `access_token` obtained via the Google OAuth2 flow. See the +[Google OAuth2 documentation](https://developers.google.com/identity/protocols/oauth2) +for details. + +```json +{ + "configuration": { + "access_token": "ya29.A0..." + } +} +``` + +#### Service Account + +Service accounts are ideal for server-to-server integrations that run without +user interaction. Use a service account when you need reliable, long-running +access without managing OAuth2 token refresh. + +To create a service account and obtain a JSON key file, follow the +[Google Cloud service account documentation](https://cloud.google.com/iam/docs/service-accounts-create). +Your OpenFn credential requires the `client_email` and `private_key` fields +from the downloaded JSON key file. + +**Share files or folders with the service account** + +A service account does not have access to any Drive files by default. You must +explicitly share each file or folder the adaptor needs to access, just like you +would share with any other Google user. + +1. Open Google Drive and locate the file or folder. +2. Click **Share**. +3. Enter the service account's `client_email` address. +4. Grant at least **Editor** access (or **Viewer** if read-only is sufficient). +5. Click **Send**. + +> **Note:** If you share a folder, the service account will have access to all +> files inside it. This is often the easiest approach for integrations that work +> with multiple files. + ## Development Clone the [adaptors monorepo](https://github.com/OpenFn/adaptors). Follow the diff --git a/packages/googlesheets/README.md b/packages/googlesheets/README.md index 67ed1db1c9..60188efe93 100644 --- a/packages/googlesheets/README.md +++ b/packages/googlesheets/README.md @@ -12,6 +12,49 @@ official [configuration-schema](https://docs.openfn.org/adaptors/packages/googlesheets-configuration-schema/) definition. +This adaptor supports two authentication methods: **OAuth2** and **Service Account**. + +#### OAuth2 + +Provide an `access_token` obtained via the Google OAuth2 flow. See the +[Google OAuth2 documentation](https://developers.google.com/identity/protocols/oauth2) +for details. + +```json +{ + "configuration": { + "access_token": "ya29.A0..." + } +} +``` + +#### Service Account + +Service accounts are ideal for server-to-server integrations that run without +user interaction. Use a service account when you need reliable, long-running +access without managing OAuth2 token refresh. + +To create a service account and obtain a JSON key file, follow the +[Google Cloud service account documentation](https://cloud.google.com/iam/docs/service-accounts-create). +Your OpenFn credential requires the `client_email` and `private_key` fields +from the downloaded JSON key file. + +**Share the spreadsheet with the service account** + +A service account does not have access to any spreadsheet by default. You must +share each spreadsheet the adaptor needs to access, just like you would share +with any other Google user. + +1. Open the spreadsheet in Google Sheets. +2. Click **Share** (top-right corner). +3. Enter the service account's `client_email` address. +4. Grant at least **Editor** access (or **Viewer** if read-only is sufficient). +5. Click **Send**. + +> **Note:** The service account email looks like +> `name@project-id.iam.gserviceaccount.com`. If you see a "This person may not +> be a Google user" warning, you can safely ignore it and proceed. + ### appendValues() Add rows to an existing sheet: From 45fc087303432c9c529a107bbb4d18c9191160ad Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Tue, 16 Jun 2026 22:52:39 +0300 Subject: [PATCH 7/9] cli vs app credential difference --- packages/gmail/README.md | 8 ++++++++ packages/googledrive/README.md | 10 +++++++--- packages/googlesheets/README.md | 10 +++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/gmail/README.md b/packages/gmail/README.md index 3682a9d4ac..8b3284acb0 100644 --- a/packages/gmail/README.md +++ b/packages/gmail/README.md @@ -256,6 +256,14 @@ This adaptor supports two authentication methods: **OAuth2** and **Service Accou ## OAuth2 +**On app.openfn.org:** Click **Sign in with Gmail** in the credential setup form. Authentication is handled for you and no manual token management is needed. + +**Using the OpenFn CLI locally:** Use the [gcloud CLI](https://cloud.google.com/sdk/docs/install) to print a temporary access token: + +```bash +gcloud auth print-access-token +``` + The Gmail adaptor implicitly uses the Gmail account of the Google account that is used to authenticate the application. Allowing the Gmail adaptor to access a Gmail account is a multi-step process. diff --git a/packages/googledrive/README.md b/packages/googledrive/README.md index ffe056a246..41c3aebcf1 100644 --- a/packages/googledrive/README.md +++ b/packages/googledrive/README.md @@ -19,9 +19,13 @@ This adaptor supports two authentication methods: **OAuth2** and **Service Accou #### OAuth2 -Provide an `access_token` obtained via the Google OAuth2 flow. See the -[Google OAuth2 documentation](https://developers.google.com/identity/protocols/oauth2) -for details. +**On app.openfn.org:** Click **Sign in with Google Drive** in the credential setup form. Authentication is handled for you and no manual token management is needed. + +**Using the OpenFn CLI locally:** Use the [gcloud CLI](https://cloud.google.com/sdk/docs/install) to print a temporary access token and provide it in your configuration: + +```bash +gcloud auth print-access-token +``` ```json { diff --git a/packages/googlesheets/README.md b/packages/googlesheets/README.md index 60188efe93..1c974281da 100644 --- a/packages/googlesheets/README.md +++ b/packages/googlesheets/README.md @@ -16,9 +16,13 @@ This adaptor supports two authentication methods: **OAuth2** and **Service Accou #### OAuth2 -Provide an `access_token` obtained via the Google OAuth2 flow. See the -[Google OAuth2 documentation](https://developers.google.com/identity/protocols/oauth2) -for details. +**On app.openfn.org:** Click **Sign in with Google Sheets** in the credential setup form. Authentication is handled for you and no manual token management is needed. + +**Using the OpenFn CLI locally:** Use the [gcloud CLI](https://cloud.google.com/sdk/docs/install) to print a temporary access token and provide it in your configuration: + +```bash +gcloud auth print-access-token +``` ```json { From 7dec8dca7f6d0118feb813cd77e93b92b2ba3fd2 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Tue, 16 Jun 2026 23:23:15 +0300 Subject: [PATCH 8/9] update gmail, googledrive, and googlesheets README auth docs Co-Authored-By: Claude Sonnet 4.6 --- packages/gmail/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/gmail/README.md b/packages/gmail/README.md index 8b3284acb0..4570bae0c2 100644 --- a/packages/gmail/README.md +++ b/packages/gmail/README.md @@ -341,9 +341,7 @@ making them well-suited for automated workflows. To create a service account and JSON key file, follow the [Google Cloud service account documentation](https://cloud.google.com/iam/docs/service-accounts-create). -Your OpenFn credential requires the `client_email`, `private_key`, and `subject` -fields. The `subject` must be the email address of the Workspace user whose -mailbox the adaptor will access -- it cannot be the service account email itself. +Your OpenFn credential requires the `client_email` and `private_key` fields from the downloaded JSON key file. You can also optionally provide a `subject` field for impersonation -- see [Google's domain-wide delegation guide](https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority) for details. For enabling domain-wide delegation and authorising the required Gmail API scopes, see the From b66e1553618783e1dccd43724296cccf2d3a1f75 Mon Sep 17 00:00:00 2001 From: Emmanuel Evance Date: Tue, 16 Jun 2026 23:33:34 +0300 Subject: [PATCH 9/9] add changeset --- .changeset/khaki-friends-switch.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/khaki-friends-switch.md diff --git a/.changeset/khaki-friends-switch.md b/.changeset/khaki-friends-switch.md new file mode 100644 index 0000000000..86bc2696b3 --- /dev/null +++ b/.changeset/khaki-friends-switch.md @@ -0,0 +1,7 @@ +--- +'@openfn/language-googlesheets': minor +'@openfn/language-googledrive': minor +'@openfn/language-gmail': minor +--- + +Add support for Google Service Account credential