From 7c56b64e8a582768d46930e39d7b8d4bbedacc0a Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Tue, 27 Aug 2024 13:50:03 +0800 Subject: [PATCH] OAuth PKCE is in. Reference PR: https://github.com/mastodon/mastodon/pull/31129 --- src/app.jsx | 30 +++++++++++++++++++--------- src/locales/en.po | 21 ++++++++++++-------- src/pages/login.jsx | 44 +++++++++++++++++++++++++++++++---------- src/utils/auth.js | 27 ++++++++++++++++++++++++- src/utils/oauth-pkce.js | 44 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 28 deletions(-) create mode 100644 src/utils/oauth-pkce.js diff --git a/src/app.jsx b/src/app.jsx index bf18e23a..4db0a8cb 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -324,6 +324,7 @@ function App() { const clientID = store.sessionCookie.get('clientID'); const clientSecret = store.sessionCookie.get('clientSecret'); const vapidKey = store.sessionCookie.get('vapidKey'); + const verifier = store.sessionCookie.get('codeVerifier'); (async () => { setUIState('loading'); @@ -332,18 +333,24 @@ function App() { client_id: clientID, client_secret: clientSecret, code, + code_verifier: verifier || undefined, }); - const client = initClient({ instance: instanceURL, accessToken }); - await Promise.allSettled([ - initPreferences(client), - initInstance(client, instanceURL), - initAccount(client, instanceURL, accessToken, vapidKey), - ]); - initStates(); + if (accessToken) { + const client = initClient({ instance: instanceURL, accessToken }); + await Promise.allSettled([ + initPreferences(client), + initInstance(client, instanceURL), + initAccount(client, instanceURL, accessToken, vapidKey), + ]); + initStates(); + window.__IGNORE_GET_ACCOUNT_ERROR__ = true; - setIsLoggedIn(true); - setUIState('default'); + setIsLoggedIn(true); + setUIState('default'); + } else { + setUIState('error'); + } })(); } else { window.__IGNORE_GET_ACCOUNT_ERROR__ = true; @@ -387,6 +394,11 @@ function App() { setUIState('default'); } } + + // Cleanup + store.sessionCookie.del('clientID'); + store.sessionCookie.del('clientSecret'); + store.sessionCookie.del('codeVerifier'); }, []); let location = useLocation(); diff --git a/src/locales/en.po b/src/locales/en.po index 30e688cb..fd83a8d0 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -1337,7 +1337,7 @@ msgid "Accounts…" msgstr "" #: src/components/nav-menu.jsx:363 -#: src/pages/login.jsx:142 +#: src/pages/login.jsx:166 #: src/pages/status.jsx:792 #: src/pages/welcome.jsx:64 msgid "Log in" @@ -1733,7 +1733,7 @@ msgstr "" #: src/components/shortcuts-settings.jsx:75 #: src/components/shortcuts-settings.jsx:84 #: src/components/shortcuts-settings.jsx:122 -#: src/pages/login.jsx:146 +#: src/pages/login.jsx:170 msgid "Instance" msgstr "" @@ -2353,7 +2353,7 @@ msgstr "Login required." #: src/compose.jsx:90 #: src/pages/http-route.jsx:91 -#: src/pages/login.jsx:223 +#: src/pages/login.jsx:247 msgid "Go home" msgstr "" @@ -3025,23 +3025,28 @@ msgstr "" msgid "No lists yet." msgstr "" -#: src/pages/login.jsx:185 +#: src/pages/login.jsx:86 +#: src/pages/login.jsx:99 +msgid "Failed to register application" +msgstr "Failed to register application" + +#: src/pages/login.jsx:209 msgid "e.g. “mastodon.social”" msgstr "" -#: src/pages/login.jsx:196 +#: src/pages/login.jsx:220 msgid "Failed to log in. Please try again or try another instance." msgstr "" -#: src/pages/login.jsx:208 +#: src/pages/login.jsx:232 msgid "Continue with {selectedInstanceText}" msgstr "" -#: src/pages/login.jsx:209 +#: src/pages/login.jsx:233 msgid "Continue" msgstr "" -#: src/pages/login.jsx:217 +#: src/pages/login.jsx:241 msgid "Don't have an account? Create one!" msgstr "" diff --git a/src/pages/login.jsx b/src/pages/login.jsx index 21d52bd4..c392e635 100644 --- a/src/pages/login.jsx +++ b/src/pages/login.jsx @@ -11,7 +11,12 @@ import LangSelector from '../components/lang-selector'; import Link from '../components/link'; import Loader from '../components/loader'; import instancesListURL from '../data/instances.json?url'; -import { getAuthorizationURL, registerApplication } from '../utils/auth'; +import { + getAuthorizationURL, + getPKCEAuthorizationURL, + registerApplication, +} from '../utils/auth'; +import { supportsPKCE } from '../utils/oauth-pkce'; import store from '../utils/store'; import useTitle from '../utils/useTitle'; @@ -63,17 +68,36 @@ function Login() { instanceURL, }); - if (client_id && client_secret) { - store.sessionCookie.set('clientID', client_id); - store.sessionCookie.set('clientSecret', client_secret); - store.sessionCookie.set('vapidKey', vapid_key); + const authPKCE = await supportsPKCE({ instanceURL }); + console.log({ authPKCE }); + if (authPKCE) { + if (client_id && client_secret) { + store.sessionCookie.set('clientID', client_id); + store.sessionCookie.set('clientSecret', client_secret); + store.sessionCookie.set('vapidKey', vapid_key); - location.href = await getAuthorizationURL({ - instanceURL, - client_id, - }); + const [url, verifier] = await getPKCEAuthorizationURL({ + instanceURL, + client_id, + }); + store.sessionCookie.set('codeVerifier', verifier); + location.href = url; + } else { + alert(t`Failed to register application`); + } } else { - alert('Failed to register application'); + if (client_id && client_secret) { + store.sessionCookie.set('clientID', client_id); + store.sessionCookie.set('clientSecret', client_secret); + store.sessionCookie.set('vapidKey', vapid_key); + + location.href = await getAuthorizationURL({ + instanceURL, + client_id, + }); + } else { + alert(t`Failed to register application`); + } } setUIState('default'); } catch (e) { diff --git a/src/utils/auth.js b/src/utils/auth.js index f03ee345..1c71191a 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -1,3 +1,5 @@ +import { generateCodeChallenge, verifier } from './oauth-pkce'; + const { PHANPY_CLIENT_NAME: CLIENT_NAME, PHANPY_WEBSITE: WEBSITE } = import.meta .env; @@ -25,6 +27,21 @@ export async function registerApplication({ instanceURL }) { return registrationJSON; } +export async function getPKCEAuthorizationURL({ instanceURL, client_id }) { + const codeVerifier = verifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const params = new URLSearchParams({ + client_id, + code_challenge_method: 'S256', + code_challenge: codeChallenge, + redirect_uri: location.origin + location.pathname, + response_type: 'code', + scope: SCOPES, + }); + const authorizationURL = `https://${instanceURL}/oauth/authorize?${params.toString()}`; + return [authorizationURL, codeVerifier]; +} + export async function getAuthorizationURL({ instanceURL, client_id }) { const authorizationParams = new URLSearchParams({ client_id, @@ -42,15 +59,23 @@ export async function getAccessToken({ client_id, client_secret, code, + code_verifier, }) { const params = new URLSearchParams({ client_id, - client_secret, redirect_uri: location.origin + location.pathname, grant_type: 'authorization_code', code, scope: SCOPES, + // client_secret, + // code_verifier, }); + if (client_secret) { + params.append('client_secret', client_secret); + } + if (code_verifier) { + params.append('code_verifier', code_verifier); + } const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, { method: 'POST', headers: { diff --git a/src/utils/oauth-pkce.js b/src/utils/oauth-pkce.js new file mode 100644 index 00000000..27f9cbdb --- /dev/null +++ b/src/utils/oauth-pkce.js @@ -0,0 +1,44 @@ +function dec2hex(dec) { + return ('0' + dec.toString(16)).slice(-2); +} +export function verifier() { + var array = new Uint32Array(56 / 2); + window.crypto.getRandomValues(array); + return Array.from(array, dec2hex).join(''); +} +function sha256(plain) { + // returns promise ArrayBuffer + const encoder = new TextEncoder(); + const data = encoder.encode(plain); + return window.crypto.subtle.digest('SHA-256', data); +} +function base64urlencode(a) { + let str = ''; + const bytes = new Uint8Array(a); + const len = bytes.byteLength; + for (var i = 0; i < len; i++) { + str += String.fromCharCode(bytes[i]); + } + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +export async function generateCodeChallenge(v) { + const hashed = await sha256(v); + return base64urlencode(hashed); +} + +// If https://mastodon.social/.well-known/oauth-authorization-server exists, means support PKCE +export async function supportsPKCE({ instanceURL }) { + if (!instanceURL) return false; + try { + const res = await fetch( + `https://${instanceURL}/.well-known/oauth-authorization-server`, + ); + if (!res.ok || res.status !== 200) return false; + return true; + } catch (e) { + return false; + } +} + +// For debugging +window.__generateCodeChallenge = generateCodeChallenge;