UNPKG

@mieweb/wikigdrive

Version:

Google Drive to MarkDown synchronization

389 lines (328 loc) 12 kB
import process from 'node:process'; import readline from 'node:readline'; import {promisify} from 'node:util'; import open from 'open'; import jsonwebtoken from 'jsonwebtoken'; import {convertResponseToError} from './driveFetch.ts'; import {ServiceAccountJson} from '../model/AccountJson.ts'; import {AuthError, GoogleUser} from '../containers/server/auth.ts'; export const SCOPES = [ 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/drive.readonly', // 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive.metadata.readonly' ]; export interface GoogleAuth { refresh_token: string | null; expiry_date: number | null; access_token: string | null; token_type?: string | null; id_token?: string | null; scopes: string[]; } export interface HasAccessToken { getAccessToken(): Promise<string>; } // https://developers.google.com/identity/protocols/oauth2/web-server async function refreshToken(client_id: string, client_secret: string, refresh_token: string): Promise<GoogleAuth> { const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id, client_secret, refresh_token }).toString() }); const json = await response.json(); return { access_token: json.access_token ? json.access_token.trim() : undefined, refresh_token, expiry_date: new Date().getTime() + Math.floor((json.expires_in - 60) * 1000), scopes: json.scope ? json.scope.split(' ') : [], token_type: json.token_type }; } export async function getCliCode(client_id: string): Promise<string> { const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({ client_id, access_type: 'offline', include_granted_scopes: 'true', scope: SCOPES.join(' '), }).toString(); const child = await open(authUrl, { wait: true }); child.stdout.on('data', (data) => { console.log(`Received chunk ${data}`); }); child.stderr.on('data', (data) => { console.log(`Received err ${data}`); }); child.on('close', (code) => { console.log(`child process exited with code ${code}`); }); child.on('message', (m) => { console.log('PARENT got message:', m); }); console.log('Authorize this app by visiting this url:', authUrl); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const question = promisify(rl.question).bind(rl); const code = await question('Enter the code from that page here: '); rl.close(); return code; } export class UserAuthClient implements HasAccessToken { private access_token: string; private refresh_token: string; private expiry_date: number; constructor(private client_id: string, private client_secret: string) { if (!client_id) throw new Error('Unknown: client_id'); if (!client_secret) throw new Error('Unknown: client_secret'); } async revokeToken(access_token: string) { const response = await fetch('https://oauth2.googleapis.com/revoke?token=' + access_token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', } }); return await response.json(); } async getWebDriveInstallUrl(redirect_uri: string, state: string): Promise<string> { return 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({ client_id: this.client_id, redirect_uri, // access_type: 'offline', prompt: 'consent select_account', response_type: 'code', include_granted_scopes: 'true', scope: [ 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/drive.install' ].join(' '), state }).toString(); } async getWebDriveShareUrl(redirect_uri: string, state: string): Promise<string> { return 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({ client_id: this.client_id, redirect_uri, // access_type: 'offline', prompt: 'consent select_account', response_type: 'code', include_granted_scopes: 'true', scope: [ 'https://www.googleapis.com/auth/drive' ].join(' '), state }).toString(); } async getUploadDriveUrl(redirect_uri: string, state: string): Promise<string> { return 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({ client_id: this.client_id, redirect_uri, // access_type: 'offline', prompt: 'consent select_account', response_type: 'code', include_granted_scopes: 'true', scope: [ 'https://www.googleapis.com/auth/drive.file' ].join(' '), state }).toString(); } async getWebAuthUrl(redirect_uri: string, state: string): Promise<string> { return 'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams({ client_id: this.client_id, redirect_uri, access_type: 'offline', // https://developers.google.com/identity/protocols/oauth2/web-server#offline // prompt: 'consent', response_type: 'code', include_granted_scopes: 'true', scope: SCOPES.join(' '), state }).toString(); } async authorizeResponseCode(code: string, redirect_uri: string): Promise<void> { const body = { client_id: this.client_id, client_secret: this.client_secret, redirect_uri: redirect_uri, access_type: 'offline', grant_type: 'authorization_code', code: code }; const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: new URLSearchParams(body).toString() }); if (response.status >= 400) { throw await convertResponseToError(response); } const json = await response.json(); const now = new Date().getTime(); const expiry_date = now + Math.floor((json.expires_in - 60) * 1000); if (!json.refresh_token) { console.error('NOREF', json, body); } const scopes = (json.scope || '').split(' '); if (!scopes.includes('https://www.googleapis.com/auth/drive.readonly') || !scopes.includes('https://www.googleapis.com/auth/drive.metadata.readonly')) { await this.revokeToken(json.access_token); const err = new AuthError('Insufficient Permission: no access to drive, check all permissions during login', 403); err.showHtml = true; throw err; } const googleAuth: GoogleAuth = { access_token: json.access_token ? json.access_token.trim() : undefined, refresh_token: json.refresh_token ? json.refresh_token.trim() : undefined, scopes: json.scope ? json.scope.split(' ') : [], token_type: json.token_type, expiry_date, id_token: json.id_token }; this.expiry_date = expiry_date; this.access_token = googleAuth.access_token; this.refresh_token = googleAuth.refresh_token; } async authorizeCookieData(access_token: string, refresh_token: string, expiry_date: number) { if (!access_token) { this.access_token = ''; return; } this.access_token = access_token; this.refresh_token = refresh_token; this.expiry_date = expiry_date; await this.checkAccessToken(); } async authorizeUserAccount(redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'): Promise<void> { // Service account // https://medium.com/@bretcameron/how-to-use-the-google-drive-api-with-javascript-57a6cc9e5262 // const email = credentials.client_email; // const key = credentials.private_key; // const keyId = credentials.private_key_id; // // const oAuth2Client = new google.auth.JWT(email, null, key, SCOPES, keyId); // // console.log(oAuth2Client); // return oAuth2Client; // https://developers.google.com/identity/protocols/oauth2/service-account // Client name: Service Account Unique ID // API interfaces: https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/drive.metadata.readonly // https://www.daimto.com/how-to-get-a-google-access-token-with-curl/ const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: this.client_id, client_secret: this.client_secret, redirect_uri }).toString() }); const json = await response.json(); this.access_token = json.access_token; this.refresh_token = json.refresh_token; this.expiry_date = json.expiry_date; } async checkAccessToken() { if (this.expiry_date) { const now = new Date().getTime(); if (now - 600 > this.expiry_date) { if (this.refresh_token) { const googleAuth: GoogleAuth = await refreshToken(this.client_id, this.client_secret, this.refresh_token); this.expiry_date = googleAuth.expiry_date; this.access_token = googleAuth.access_token; } } } } async getAccessToken(): Promise<string> { await this.checkAccessToken(); return this.access_token; } setCredentials(google_auth: GoogleAuth) { this.expiry_date = google_auth.expiry_date; this.refresh_token = google_auth.refresh_token; this.access_token = google_auth.access_token; } async getUser(access_token: string): Promise<GoogleUser> { const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo?access_token=' + access_token); if (response.status >= 400) { throw await convertResponseToError(response); } const json = await response.json(); return { id: json.id, email: json.email, name: json.name }; } async getAuthData(): Promise<{ google_access_token: string, google_refresh_token: string, google_expiry_date: number }> { return { google_access_token: this.access_token, google_refresh_token: this.refresh_token, google_expiry_date: this.expiry_date, }; } } export class ServiceAuthClient implements HasAccessToken { private access_token: string; private expiry_date: number; constructor(private readonly service_account_json: ServiceAccountJson) { } async fetchAccessToken() { // https://tanaikech.github.io/2019/04/02/retrieving-access-token-using-service-account-for-node.js-without-using-googleapis/ const now = Math.floor(Date.now() / 1000); const url = 'https://www.googleapis.com/oauth2/v4/token'; const jwt = jsonwebtoken.sign({ iss: this.service_account_json.client_email, scope: SCOPES.join(' '), aud: url, exp: (now + 3600), iat: now, }, this.service_account_json.private_key, { algorithm: 'RS256' }); const response = await fetch(url, { method: 'post', body: JSON.stringify({ assertion: jwt, grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', }), }); const json = await response.json(); /* { access_token: 'aaa.bbb.ccc', expires_in: 3599, token_type: 'Bearer' } */ this.expiry_date = new Date().getTime() + Math.floor((json.expires_in - 60) * 1000); this.access_token = json.access_token ? json.access_token.trim() : undefined; } async getAccessToken(): Promise<string> { if (this.expiry_date) { const now = new Date().getTime(); if (now - 600 > this.expiry_date) { await this.fetchAccessToken(); } } if (!this.access_token) { await this.fetchAccessToken(); } return this.access_token; } }