Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions apps/server/src/api-data/sheets/DeviceAuthProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Credentials, OAuth2Client } from 'google-auth-library';
import { logger } from '../../classes/Logger.js';
import { LogOrigin, MaybeString, AuthenticationStatus } from 'ontime-types';
import { consoleSubdued } from '../../utils/console.js';
import { type ClientSecret } from './sheets.utils.js';

const codesUrl = 'https://oauth2.googleapis.com/device/code';
const tokenUrl = 'https://oauth2.googleapis.com/token';
const grantType = 'urn:ietf:params:oauth:grant-type:device_code';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';

type CodesResponse = {
device_code: string;
expires_in: number;
interval: number;
user_code: string;
verification_url: string;
};

export class DeviceAuthProvider {
private currentAuthClient: OAuth2Client | null = null;
private currentAuthUrl: MaybeString = null;
private currentAuthCode: MaybeString = null;
private pollInterval: NodeJS.Timeout | null = null;
private cleanupTimeout: NodeJS.Timeout | null = null;

constructor() {}

getStatus(): AuthenticationStatus {
if (this.cleanupTimeout) {
return 'pending';
}
return this.currentAuthClient ? 'authenticated' : 'not_authenticated';
}

getAuthClient(): OAuth2Client | null {
return this.currentAuthClient;
}

getPendingData() {
return {
verification_url: this.currentAuthUrl,
user_code: this.currentAuthCode,
};
}

revoke() {
this.reset();
}

private reset() {
this.currentAuthClient = null;
this.currentAuthUrl = null;
this.currentAuthCode = null;

if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = null;
}
}

async authenticate(clientSecret: ClientSecret, onAuthenticated: (client: OAuth2Client) => Promise<void>) {
const { device_code, expires_in, interval, user_code, verification_url } = await this.getDeviceCodes(clientSecret);
this.currentAuthUrl = verification_url;
this.currentAuthCode = user_code;

this.verifyConnection(clientSecret, device_code, interval, expires_in, onAuthenticated);

return { verification_url, user_code };
}

private async getDeviceCodes(clientSecret: ClientSecret): Promise<CodesResponse> {
const response = await fetch(codesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
scope: sheetScope,
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`);
}

const deviceCodes: CodesResponse = await response.json();
return deviceCodes;
}

private verifyConnection(
clientSecret: ClientSecret,
device_code: string,
interval: number,
expires_in: number,
onAuthenticated: (client: OAuth2Client) => Promise<void>,
) {
logger.info(LogOrigin.Server, 'Start polling for auth...');

this.pollInterval = setInterval(() => this.pollForAuth(clientSecret, device_code, onAuthenticated), interval * 1000);

if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
}
this.cleanupTimeout = setTimeout(() => {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.cleanupTimeout = null;
}, expires_in * 1000);
}

private async pollForAuth(
clientSecret: ClientSecret,
device_code: string,
onAuthenticated: (client: OAuth2Client) => Promise<void>,
) {
try {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientSecret.installed.client_id,
client_secret: clientSecret.installed.client_secret,
device_code,
grant_type: grantType,
}),
});

if (response.status === 428) {
consoleSubdued('User not auth yet');
return;
}

if (!response.ok) {
logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`);
return;
}

const auth: Credentials = await response.json();

logger.info(LogOrigin.Server, 'Successfully Authenticated');
const client = new OAuth2Client({
clientId: clientSecret.installed.client_id,
clientSecret: clientSecret.installed.client_secret,
});

client.setCredentials({
refresh_token: auth.refresh_token,
access_token: auth.access_token,
scope: auth.scope,
token_type: auth.token_type,
});

this.currentAuthClient = client;

if (this.cleanupTimeout) {
clearTimeout(this.cleanupTimeout);
this.cleanupTimeout = null;
}

if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}

await onAuthenticated(client);
} catch (error) {
logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`);
}
}
}
41 changes: 41 additions & 0 deletions apps/server/src/api-data/sheets/GoogleSheetsClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { sheets, type sheets_v4 } from '@googleapis/sheets';
import { OAuth2Client } from 'google-auth-library';

export class GoogleSheetsClient {
private sheets: sheets_v4.Sheets;

constructor(auth: OAuth2Client) {
this.sheets = sheets({ version: 'v4', auth });
}

async getSpreadsheet(spreadsheetId: string) {
return this.sheets.spreadsheets.get({
spreadsheetId,
includeGridData: false,
});
}

async getValues(spreadsheetId: string, range: string) {
return this.sheets.spreadsheets.values.get({
spreadsheetId,
valueRenderOption: 'FORMATTED_VALUE',
majorDimension: 'ROWS',
range,
});
}

async batchUpdate(
spreadsheetId: string,
requests: sheets_v4.Schema$Request[],
responseRanges?: string[],
) {
return this.sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
includeSpreadsheetInResponse: false,
responseRanges,
requests,
},
});
}
}
138 changes: 138 additions & 0 deletions apps/server/src/api-data/sheets/__tests__/DeviceAuthProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DeviceAuthProvider } from '../DeviceAuthProvider.js';

describe('DeviceAuthProvider', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('should start with not_authenticated status', () => {
const provider = new DeviceAuthProvider();
expect(provider.getStatus()).toBe('not_authenticated');
});

it('should initiate authentication and poll', async () => {
const provider = new DeviceAuthProvider();
const mockClientSecret = {
installed: {
client_id: 'id',
client_secret: 'secret',
auth_uri: '',
token_uri: '',
auth_provider_x509_cert_url: '',
},
};

(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
device_code: 'dc',
expires_in: 60,
interval: 5,
user_code: 'uc',
verification_url: 'vurl',
}),
});

const onAuth = vi.fn();
const result = await provider.authenticate(mockClientSecret, onAuth);

expect(result).toEqual({
verification_url: 'vurl',
user_code: 'uc',
});
expect(provider.getStatus()).toBe('pending');
expect(fetch).toHaveBeenCalledTimes(1);

// Poll 1: not yet authorized
(fetch as any).mockResolvedValueOnce({
status: 428,
ok: false,
});

vi.advanceTimersByTime(5000);
// Poll happens in background
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(provider.getStatus()).toBe('pending');

// Poll 2: success
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
access_token: 'at',
refresh_token: 'rt',
scope: 'scope',
token_type: 'Bearer',
}),
});

vi.advanceTimersByTime(5000);
await vi.waitFor(() => expect(onAuth).toHaveBeenCalled());
expect(provider.getStatus()).toBe('authenticated');
});

it('should stop polling after expiration', async () => {
const provider = new DeviceAuthProvider();
const mockClientSecret = {
installed: {
client_id: 'id',
client_secret: 'secret',
auth_uri: '',
token_uri: '',
auth_provider_x509_cert_url: '',
},
};

(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
device_code: 'dc',
expires_in: 10,
interval: 5,
user_code: 'uc',
verification_url: 'vurl',
}),
});

await provider.authenticate(mockClientSecret, vi.fn());
expect(provider.getStatus()).toBe('pending');

vi.advanceTimersByTime(11000);
expect(provider.getStatus()).toBe('not_authenticated');
});

it('should reset on revoke', async () => {
const provider = new DeviceAuthProvider();
const mockClientSecret = {
installed: {
client_id: 'id',
client_secret: 'secret',
auth_uri: '',
token_uri: '',
auth_provider_x509_cert_url: '',
},
};

(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({
device_code: 'dc',
expires_in: 60,
interval: 5,
user_code: 'uc',
verification_url: 'vurl',
}),
});

await provider.authenticate(mockClientSecret, vi.fn());
provider.revoke();
expect(provider.getStatus()).toBe('not_authenticated');
expect(provider.getAuthClient()).toBeNull();
});
});
Loading
Loading