From bf156fd0f57a374bbafcf74a44ed8000d2218fac Mon Sep 17 00:00:00 2001 From: "Shaun A. Noordin" Date: Wed, 11 Dec 2024 15:27:41 +0000 Subject: [PATCH 1/5] ExperimentalAuth: add checkCurrentUser() and checkBearerToken()'s skeletons --- packages/lib-panoptes-js/dev/index.html | 5 ++++ packages/lib-panoptes-js/dev/index.js | 26 +++++++++++++++++-- .../lib-panoptes-js/src/experimental-auth.js | 25 ++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/lib-panoptes-js/dev/index.html b/packages/lib-panoptes-js/dev/index.html index afa92cc1f8..c68d4e3f29 100644 --- a/packages/lib-panoptes-js/dev/index.html +++ b/packages/lib-panoptes-js/dev/index.html @@ -21,10 +21,15 @@

Panoptes.js dev app

+
+

Functions

+ +
+

Sign In


diff --git a/packages/lib-panoptes-js/dev/index.js b/packages/lib-panoptes-js/dev/index.js index 4b846511c9..a656b6f08b 100644 --- a/packages/lib-panoptes-js/dev/index.js +++ b/packages/lib-panoptes-js/dev/index.js @@ -1,16 +1,38 @@ -import { signIn, addEventListener } from '@src/experimental-auth.js' +import { + checkBearerToken, + checkCurrentUser, + signIn, + addEventListener, +} from '@src/experimental-auth.js' class App { constructor () { this.html = { + checkCurrentUserButton: document.getElementById('check-current-user-button'), loginForm: document.getElementById('login-form'), message: document.getElementById('message'), } + this.html.checkCurrentUserButton.addEventListener('click', this.checkCurrentUserButton_onClick.bind(this)) this.html.loginForm.addEventListener('submit', this.loginForm_onSubmit.bind(this)) addEventListener('change', this.onAuthChange) } + async checkCurrentUserButton_onClick (e) { + try { + const user = await checkCurrentUser() + if (user) { + this.html.message.innerHTML += `> Current user: ${user.login}\n` + } else { + this.html.message.innerHTML += `> Current user: [nobody] \n` + } + } catch (err) { + console.error(err) + this.html.message.innerHTML += `> [ERROR] ${err.toString()}\n` + } + return false + } + async loginForm_onSubmit (e) { const formData = new FormData(e.target) e.preventDefault() @@ -25,7 +47,7 @@ class App { } } catch (err) { console.error(err) - this.html.message.innerHTML += `> ${err.toString()}\n` + this.html.message.innerHTML += `> [ERROR] ${err.toString()}\n` } return false } diff --git a/packages/lib-panoptes-js/src/experimental-auth.js b/packages/lib-panoptes-js/src/experimental-auth.js index 7f500950b3..afa3606fdd 100644 --- a/packages/lib-panoptes-js/src/experimental-auth.js +++ b/packages/lib-panoptes-js/src/experimental-auth.js @@ -271,7 +271,32 @@ async function signIn (login, password, _store) { } } +/* +Checks if there's a current, signed-in user. + */ +async function checkCurrentUser (_store) { + +} + +// Alias for checkCurrentUser +async function checkCurrent(_store) { return checkCurrentUser(_store) } + +/* +Fetches current user from Panoptes's /me endpoint. + */ +async function fetchCurrentUser (_store) { + +} + +/* +Checks if there's an existing Bearer Token. + */ +async function checkBearerToken (_store) {} + export { + checkBearerToken, + checkCurrent, + checkCurrentUser, signIn, addEventListener, removeEventListener, From bc83ea854951e8fb119b70c90b92e2a9bafb7e5c Mon Sep 17 00:00:00 2001 From: "Shaun A. Noordin" Date: Wed, 11 Dec 2024 22:21:14 +0000 Subject: [PATCH 2/5] checkCurrentUser(): add 'fetch bearer token' action --- .../lib-panoptes-js/src/experimental-auth.js | 109 +++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/lib-panoptes-js/src/experimental-auth.js b/packages/lib-panoptes-js/src/experimental-auth.js index afa3606fdd..4c9cc50ffa 100644 --- a/packages/lib-panoptes-js/src/experimental-auth.js +++ b/packages/lib-panoptes-js/src/experimental-auth.js @@ -145,6 +145,7 @@ async function signIn (login, password, _store) { // - This "authenticity token", as it will be later be called, prevents third // parties from simply replaying the HTTPs-encoded sign-in request. // - In our case, the CSRF token is provided Panoptes itself. + const request1 = new Request(`https://panoptes-staging.zooniverse.org/users/sign_in/?now=${Date.now()}`, { credentials: 'include', method: 'GET', @@ -160,7 +161,7 @@ async function signIn (login, password, _store) { // - These HTTP cookies identify requests as coming from us (or rather, from // our particular session. // - This is how request2 (submit username & password) and request3 (request - // bearer token for a logged in user) are magically linked and recognised + // bearer token for a logged-in user) are magically linked and recognised // as coming from the same person/session, even though request3 isn't // providing any login data explicitly via the JavaScript code. // - HTTP-only cookies can't be viewed or edited by JavaScript, as it happens. @@ -261,7 +262,7 @@ async function signIn (login, password, _store) { store.bearerToken = bearerToken, store.bearerTokenExpiry = bearerTokenExpiry store.refreshToken = refreshToken - _broadcastEvent('change', userData) + _broadcastEvent('change', userData, store) return userData @@ -275,7 +276,111 @@ async function signIn (login, password, _store) { Checks if there's a current, signed-in user. */ async function checkCurrentUser (_store) { + const store = _store || globalStore + + // Step 1: do we already have a user in the store? + if (store.userData) { + + // If yes, just return the user. + return store.userData + + } else { + // If no, let's ask Panoptes who's the current user. + console.log('Checking current user') + + let user = null + + try { + + // Step 2: get the bearer token. + // If user has previously signed in on this web browser, (e.g. they signed + // in on window 1, and then we attempt to get the bearer token on + // window 2,) then the Panoptes API will *automagically* know that the + // request is coming from the same signed-in user. + // This appears to be the result of certain HTTP-only cookies - + // _Panoptes_session and remember_user_token - being passed along with the + // request header. + + // TODO: figure out how the browser stores these http-only cookies. + // On Chrome 131, when opening a new window, the Applications > Cookies + // shows an empty list. But an initial request to /oauth/token will + // somehow magically contain the correct cookies in its header. 🤷‍♀️ + // (After the first response is received, the Cookies list in Chrome is + // updated to list the expected cookies.) + + const request1 = new Request(`https://panoptes-staging.zooniverse.org/oauth/token`, { + body: JSON.stringify({ + client_id: '535759b966935c297be11913acee7a9ca17c025f9f15520e7504728e71110a27', + grant_type: 'password', + }), + credentials: 'include', + method: 'POST', + headers: PANOPTES_HEADERS, + }) + const response1 = await fetch(request1) + + // if response is a 401, then there's no logged-in user. + if (response1.status === 401) { + return null + } + + // Extract data and check for errors. + if (!response1.ok) { + const jsonData1 = await response1.json() + const error = jsonData1?.error || 'No idea what went wrong; no specific error message detected.' + throw new Error(`Error from API. ${error}`) + } + const jsonData1 = await response1.json() + const bearerToken = jsonData1?.access_token // The bearer token is short-lived + const refreshToken = jsonData1?.refresh_token // The refresh token is used to get new bearer tokens. + const bearerTokenExpiry = Date.now() + (jsonData1?.expires_in * 1000) // Use Date.now() instead of response.created_at, because it keeps future "has expired?" comparisons consistent to the client's clock instead of the server's clock. + + if (!bearerToken || !refreshToken) { + // throw new Error('Impossible API response. access_token and/or refresh_token unavailable.') + } else if (jsonData1?.token_type !== 'Bearer') { + throw new Error('Impossible API response. Token wasn\'t of type "Bearer".') + } else if (isNaN(bearerTokenExpiry)) { + throw new Error('Impossible API response. Token expiry can\'t be calculated.') + } else if (bearerTokenExpiry <= Date.now()) { + throw new Error('Impossible API response. Token has already expired for some reason.') + } + + return + + const request2 = new Request(`https://panoptes-staging.zooniverse.org/api/me`, { + credentials: 'include', + method: 'GET', + headers: PANOPTES_HEADERS, + }) + + const response2 = await fetch(request2) + + // Extract data and check for errors. + console.log('+++ response2: ', response2) + console.log('+++ response2.json: ', await response2?.json()) + + /* + if (!response.ok) { + const jsonData2 = await response2.json() + const error = jsonData2?.error || 'No idea what went wrong; no specific error message detected.' + throw new Error(`Error from API. ${error}`) + } + const jsonData2 = await response2.json() + const userData = jsonData2?.users?.[0] + if (!userData) { + throw new Error('Impossible API response. No user returned.') + } else if (userData.login && userData.login !== login) { + throw new Error('Impossible API response. User returned is different from login attempt. Did you forget to sign out first?') + } + */ + + } catch (err) { + console.error('+++ checkCurrentUser error: ', err) + } + + return user + } } // Alias for checkCurrentUser From ad8c384cfb3194234f3041f6c5c2f9325448563a Mon Sep 17 00:00:00 2001 From: "Shaun A. Noordin" Date: Thu, 12 Dec 2024 00:12:56 +0000 Subject: [PATCH 3/5] checkCurrent(): successfully fetch user data for /me endpoint, but I'm not sure why --- .../lib-panoptes-js/src/experimental-auth.js | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/lib-panoptes-js/src/experimental-auth.js b/packages/lib-panoptes-js/src/experimental-auth.js index 4c9cc50ffa..8fffef5a84 100644 --- a/packages/lib-panoptes-js/src/experimental-auth.js +++ b/packages/lib-panoptes-js/src/experimental-auth.js @@ -279,7 +279,8 @@ async function checkCurrentUser (_store) { const store = _store || globalStore // Step 1: do we already have a user in the store? - if (store.userData) { + // DEBUG if (store.userData) { + if (false) { // If yes, just return the user. return store.userData @@ -337,7 +338,7 @@ async function checkCurrentUser (_store) { const bearerTokenExpiry = Date.now() + (jsonData1?.expires_in * 1000) // Use Date.now() instead of response.created_at, because it keeps future "has expired?" comparisons consistent to the client's clock instead of the server's clock. if (!bearerToken || !refreshToken) { - // throw new Error('Impossible API response. access_token and/or refresh_token unavailable.') + throw new Error('Impossible API response. access_token and/or refresh_token unavailable.') } else if (jsonData1?.token_type !== 'Bearer') { throw new Error('Impossible API response. Token wasn\'t of type "Bearer".') } else if (isNaN(bearerTokenExpiry)) { @@ -346,12 +347,18 @@ async function checkCurrentUser (_store) { throw new Error('Impossible API response. Token has already expired for some reason.') } - return - - const request2 = new Request(`https://panoptes-staging.zooniverse.org/api/me`, { - credentials: 'include', + // TODO: figure out why /me specifically requires such an odd header + credentials + + const request2 = new Request(`https://panoptes-staging.zooniverse.org/api/me?http_cache=true`, { + // credentials: 'include', // ❗️ Don't use 'include'. + credentials: 'same-origin', method: 'GET', - headers: PANOPTES_HEADERS, + headers: { + // ...PANOPTES_HEADERS, // ❗️ Don't use standard headers. + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.api+json; version=1', + Authorization: `Bearer ${bearerToken}` + }, }) const response2 = await fetch(request2) From 654cc4f450c12a456ea977fb30d57f6f08289228 Mon Sep 17 00:00:00 2001 From: "Shaun A. Noordin" Date: Thu, 12 Dec 2024 00:31:03 +0000 Subject: [PATCH 4/5] checkCurrentUser(): fetch and save user data. Function is now complete. --- .../lib-panoptes-js/src/experimental-auth.js | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/lib-panoptes-js/src/experimental-auth.js b/packages/lib-panoptes-js/src/experimental-auth.js index 8fffef5a84..5c10176483 100644 --- a/packages/lib-panoptes-js/src/experimental-auth.js +++ b/packages/lib-panoptes-js/src/experimental-auth.js @@ -252,18 +252,16 @@ async function signIn (login, password, _store) { throw new Error('Impossible API response. Token has already expired for some reason.') } - console.log('+++ signIn() Results: ', - '\n\n userData:', userData, - '\n\n bearerToken', bearerToken, - '\n\n bearerTokenExpiry', new Date(bearerTokenExpiry), - '\n\n refreshToken', refreshToken, - ) + // Step 4: update the store. + store.userData = userData store.bearerToken = bearerToken, store.bearerTokenExpiry = bearerTokenExpiry store.refreshToken = refreshToken _broadcastEvent('change', userData, store) + // Step 5: return user data. + return userData } catch (err) { @@ -290,8 +288,6 @@ async function checkCurrentUser (_store) { // If no, let's ask Panoptes who's the current user. console.log('Checking current user') - let user = null - try { // Step 2: get the bearer token. @@ -364,42 +360,45 @@ async function checkCurrentUser (_store) { const response2 = await fetch(request2) // Extract data and check for errors. - console.log('+++ response2: ', response2) - console.log('+++ response2.json: ', await response2?.json()) - - /* - if (!response.ok) { - const jsonData2 = await response2.json() - const error = jsonData2?.error || 'No idea what went wrong; no specific error message detected.' - throw new Error(`Error from API. ${error}`) + // Note: the /me endpoint returns errors in a different format than, say, /sign_in or /oauth/token + if (!response2.ok) { + if (response2.status === 404) { + // If there's no signed-in user, a 404 is expected. + return null + } else { + // Any error response than a 404 isn't expected. + throw new Error(`Error from API. ${response2.status}`) + } } + const jsonData2 = await response2.json() const userData = jsonData2?.users?.[0] if (!userData) { throw new Error('Impossible API response. No user returned.') - } else if (userData.login && userData.login !== login) { - throw new Error('Impossible API response. User returned is different from login attempt. Did you forget to sign out first?') } - */ + + // Step 4: update the store. + + store.userData = userData + store.bearerToken = bearerToken, + store.bearerTokenExpiry = bearerTokenExpiry + store.refreshToken = refreshToken + _broadcastEvent('change', userData, store) + + // Step 5: return user data. + + return userData } catch (err) { - console.error('+++ checkCurrentUser error: ', err) + console.error('Panoptes.js auth.checkCurrentUser(): ', err) + throw(err) } - - return user } } // Alias for checkCurrentUser async function checkCurrent(_store) { return checkCurrentUser(_store) } -/* -Fetches current user from Panoptes's /me endpoint. - */ -async function fetchCurrentUser (_store) { - -} - /* Checks if there's an existing Bearer Token. */ From 16366ee62a48c9f0e4c76dd9c75b6eaf2b3da8af Mon Sep 17 00:00:00 2001 From: "Shaun A. Noordin" Date: Fri, 13 Dec 2024 23:10:24 +0000 Subject: [PATCH 5/5] checkCurrentUser: add documentation. Remove debug code. --- .../lib-panoptes-js/src/experimental-auth.js | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/lib-panoptes-js/src/experimental-auth.js b/packages/lib-panoptes-js/src/experimental-auth.js index 5c10176483..422340ed8e 100644 --- a/packages/lib-panoptes-js/src/experimental-auth.js +++ b/packages/lib-panoptes-js/src/experimental-auth.js @@ -107,7 +107,7 @@ function _broadcastEvent (eventType, args, _store) { } /* -Sign In to Zooniverse. +Sign in to Zooniverse. This action attempts to sign the user into the Panoptes system, using the user's login and password. If successful, the function returns a Panoptes User object, and the store is updated with the signed-in user's details @@ -118,7 +118,8 @@ Input: - password: (string) user's password - _store: (optional) data store. See default globalStore. Output: -- (object) Panoptes User resource. +- (object) Panoptes User resource, on success. +- Throws an error on failure. Side Effects: - on success, _store's userData, bearerToken, bearerTokenExpiry, and refreshToken are updated. @@ -271,14 +272,36 @@ async function signIn (login, password, _store) { } /* -Checks if there's a current, signed-in user. +Check for current signed-in Zooniverse user. +This function attempts to check if there's currently a signed-in user. First, +it checks the store to see if there's any user data. If there isn't, then it +checks the Panoptes API. If successful, the function returns a Panoptes User +object, and the store is updated with the signed-in user's details (including +their access tokens). + +Input: +- _store: (optional) data store. See default globalStore. +Output: +- (object) Panoptes User resource, if there's a signed-in user. +- null, if there's NO signed-in user. +- Throws an error if Panoptes API can't be checked properly +Side Effects: +- on success, _store's userData, bearerToken, bearerTokenExpiry, and + refreshToken are updated. +Events: +- "change": when the user successfully signs in, the Panoptes User object is + broadcasted with the event. +Possible Errors: +- Uncategorised network errors. +- Extremely unlikely API errors: invalid CSRF tokens, etc. Don't worry about these. +- Note: a 401 from the /me endpoint is NOT considered an error, but an expected + response when there's no signed-in user. */ async function checkCurrentUser (_store) { const store = _store || globalStore // Step 1: do we already have a user in the store? - // DEBUG if (store.userData) { - if (false) { + if (store.userData) { // If yes, just return the user. return store.userData @@ -402,7 +425,7 @@ async function checkCurrent(_store) { return checkCurrentUser(_store) } /* Checks if there's an existing Bearer Token. */ -async function checkBearerToken (_store) {} +async function checkBearerToken (_store) { /* To be implemented after frontend dev auth presentation. */ } export { checkBearerToken,