Created
August 31, 2020 11:41
-
-
Save kewisch/be78a55577e58c6d88b5b39ea598365c to your computer and use it in GitHub Desktop.
Proof of Concept: Google OAuth2 without the browser.identity API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* This Source Code Form is subject to the terms of the Mozilla Public | |
* License, v. 2.0. If a copy of the MPL was not distributed with this | |
* file, You can obtain one at http://mozilla.org/MPL/2.0/. | |
* Portions Copyright (C) Philipp Kewisch, 2020 */ | |
class OAuth2 { | |
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" | |
APPROVAL_URL = "https://accounts.google.com/o/oauth2/approval/v2" | |
TOKEN_URL = "https://oauth2.googleapis.com/token" | |
LOGOUT_URL = "https://oauth2.googleapis.com/revoke" | |
EXPIRE_GRACE_SECONDS = 60 | |
WINDOW_WIDTH = 430 | |
WINDOW_HEIGHT = 750 | |
constructor({ clientId, clientSecret, scope }) { | |
this.clientId = clientId; | |
this.clientSecret = clientSecret; | |
this.scope = scope; | |
} | |
get expired() { | |
return Date.now() > this.expires; | |
} | |
async _approvalUrlViaTabs(wnd) { | |
return new Promise((resolve, reject) => { | |
let tabListener = (tabId, changeInfo) => { | |
if (changeInfo.url) { | |
browser.tabs.onUpdated.removeListener(tabListener); | |
browser.windows.onRemoved.removeListener(windowListener); | |
resolve(new URL(changeInfo.url)); | |
} | |
}; | |
let windowListener = (windowId) => { | |
if (windowId == wnd.id) { | |
browser.tabs.onUpdated.removeListener(tabListener); | |
browser.windows.onRemoved.removeListener(windowListener); | |
reject({ error: "canceled" }); | |
} | |
}; | |
browser.windows.onRemoved.addListener(windowListener); | |
browser.tabs.onUpdated.addListener(tabListener, { | |
urls: [this.APPROVAL_URL + "*"], | |
windowId: wnd.id | |
}); | |
}); | |
} | |
async _approvalUrlViaWebRequest(wnd) { | |
return new Promise((resolve, reject) => { | |
let listener = (details) => { | |
browser.webRequest.onBeforeRequest.removeListener(listener); | |
browser.windows.onRemoved.removeListener(windowListener); | |
resolve(new URL(details.url)); | |
}; | |
let windowListener = (windowId) => { | |
if (windowId == wnd.id) { | |
browser.webRequest.onBeforeRequest.removeListener(listener); | |
browser.windows.onRemoved.removeListener(windowListener); | |
reject({ error: "canceled" }); | |
} | |
}; | |
browser.windows.onRemoved.addListener(windowListener); | |
browser.webRequest.onBeforeRequest.addListener(listener, { | |
urls: [this.APPROVAL_URL + "*"], | |
windowId: wnd.id | |
}); | |
}); | |
} | |
async login({ titlePreface="", loginHint="" }) { | |
// Create a window initiating the OAuth2 login process | |
let params = new URLSearchParams({ | |
client_id: this.clientId, | |
scope: this.scope, | |
response_type: "code", | |
redirect_uri: "urn:ietf:wg:oauth:2.0:oob:auto", | |
login_hint: loginHint, | |
hl: browser.i18n.getUILanguage(), // eslint-disable-line id-length | |
}); | |
let wnd = await browser.windows.create({ | |
titlePreface: titlePreface, | |
type: "popup", | |
url: this.AUTH_URL + "?" + params, | |
width: this.WINDOW_WIDTH, | |
height: this.WINDOW_HEIGHT, | |
}); | |
// Wait for the approval request to settle. There are two ways to do this: via the tabs | |
// permission, or via webRequest. Use the method that uses the least amount of extra | |
// permissions. | |
let approvalUrl = await this._approvalUrlViaTabs(wnd); | |
await browser.windows.remove(wnd.id); | |
// Turn the approval code into the refresh and access tokens | |
params = new URLSearchParams(approvalUrl.search.substr(1)); | |
if (params.get("response").startsWith("error=")) { | |
throw { error: params.get("response").substr(6) }; | |
} | |
let response = await fetch(this.TOKEN_URL, { | |
method: "POST", | |
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", }, | |
body: new URLSearchParams({ | |
client_id: this.clientId, | |
client_secret: this.clientSecret, | |
code: params.get("approvalCode"), | |
grant_type: "authorization_code", | |
redirect_uri: "urn:ietf:wg:oauth:2.0:oob:auto" | |
}) | |
}); | |
if (!response.ok) { | |
throw { error: "request_error" }; | |
} | |
let details = await response.json(); | |
this.accessToken = details.access_token; | |
this.refreshToken = details.refresh_token; | |
this.grantedScopes = details.scope; | |
this.expires = new Date(Date.now() + 1000 * (details.expires_in - this.EXPIRE_GRACE_SECONDS)); | |
} | |
async refresh(force=false) { | |
if (!force && !this.expired) { | |
return; | |
} | |
let response = await fetch(this.TOKEN_URL, { | |
method: "POST", | |
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", }, | |
body: new URLSearchParams({ | |
client_id: this.clientId, | |
client_secret: this.clientSecret, | |
grant_type: "refresh_token", | |
refresh_token: this.refreshToken | |
}) | |
}); | |
if (!response.ok) { | |
throw { error: "request_error" }; | |
} | |
let details = await response.json(); | |
this.accessToken = details.access_token; | |
this.expires = new Date(Date.now() + 1000 * (details.expires_in - this.EXPIRE_GRACE_SECONDS)); | |
this.grantedScopes = details.scope; | |
} | |
async logout() { | |
let token = this.expired ? this.refreshToken : this.accessToken; | |
let response = await fetch(this.LOGOUT_URL, { | |
method: "POST", | |
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", }, | |
body: new URLSearchParams({ token }) | |
}); | |
if (!response.ok) { | |
throw { error: "request_error" }; | |
} | |
this.accessToken = null; | |
this.refreshToken = null; | |
this.grantedScopes = null; | |
this.expires = null; | |
} | |
} | |
browser.browserAction.onClicked.addListener(async () => { | |
try { | |
let oauth = new OAuth2({ | |
clientId: "YOUR_CLIENT_ID_HERE", | |
clientSecret: "YOUR_CLIENT_SECRET_HERE", | |
scope: "SPACE_SEPARATED_SCOPES_HERE", | |
}); | |
await oauth.login({ | |
titlePreface: "Google OAuth Login: ", | |
loginHint: "ACCOUNT_EMAIL_HERE", | |
}); | |
console.log("AUTHINFO", oauth.accessToken, oauth.refreshToken); | |
await oauth.refresh(true); | |
console.log("AUTHINFO", oauth.accessToken, oauth.refreshToken); | |
await oauth.logout(); | |
} catch (e) { | |
console.error(e); | |
} | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This Source Code Form is subject to the terms of the Mozilla Public | |
// License, v. 2.0. If a copy of the MPL was not distributed with this | |
// file, You can obtain one at http://mozilla.org/MPL/2.0/. | |
// Portions Copyright (C) Philipp Kewisch, 2020 | |
{ | |
"manifest_version": 2, | |
"name": "Google OAuth2 PoC", | |
"description": "Proof of concept for OAuth2 without the browser.identity API", | |
"version": "1.0.0", | |
"browser_specific_settings": { | |
"gecko": { | |
"id": "[email protected]" | |
} | |
}, | |
"icons": { | |
"128": "images/addon.svg" | |
}, | |
"permissions": [ | |
// There are two methods to wait for the OAuth process to complete. Via webRequest: | |
// "webRequest", | |
// "https://accounts.google.com/o/oauth2/approval/v2", | |
// ...or via tabs.onUpdated. Pick whatever permission you already use. | |
"tabs" | |
], | |
"browser_action": { | |
"default_icon": "images/addon.svg", | |
"default_title": "Start Google OAuth" | |
}, | |
"background": { | |
"scripts": [ | |
"background.js" | |
] | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment