@mieweb/wikigdrive
Version:
Google Drive to MarkDown synchronization
389 lines (328 loc) • 12 kB
text/typescript
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;
}
}