-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Panoptes.js: add lib-panoptes-js dev server, add experimental auth #6375
Open
shaunanoordin
wants to merge
23
commits into
master
Choose a base branch
from
remove-pjc-experiment-pt1
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
fc17511
PanoptesJS: fix typo in example in README
shaunanoordin 2b042de
Add experimental auth
shaunanoordin e7be381
Experimental Auth: add listener system
shaunanoordin b97b746
Experimental Auth: fix tests
shaunanoordin 7b5a5a1
Experimental Auth: remove tests, everything seems to work in principle
shaunanoordin caf6ccc
PanoptesJS: add dev server
shaunanoordin b599f3d
PanoptesJS: add missing aliases to webpack
shaunanoordin 2f4795c
ExperimentalAuth: setup skeleton for signIn()
shaunanoordin 3327c1d
ExperimentalAuth WIP: add chain of signIn() functionality
shaunanoordin 4abd28b
Fix console.log
shaunanoordin 4da88f6
WIP: rework SignIn() code. Understand foundations of sign in to Panop…
shaunanoordin 1e4e734
Add sign in action, and fetch bearer token
shaunanoordin f94b118
signIn: add error handling
shaunanoordin 762112d
signIn: functionality complete
shaunanoordin 42588e1
signIn: update comments
shaunanoordin cf81267
ExperimentalAuth: trim down unused placeholder code. Focus on functio…
shaunanoordin c0ba309
ExperimentalAuth: add functional tests/examples for event listener sy…
shaunanoordin 3c8617c
ExperimentalAuth: change onAuthChange() message to something more fun
shaunanoordin 16a6f27
ExperimentalAuth: improve messages from signIn(). Add message element…
shaunanoordin 86318bc
ExperimentalAuth: update docs on signIn()
shaunanoordin c7bfb68
ExperimentalAuth: update returns/thrown errors for event listeners. U…
shaunanoordin 6cf3dd2
ExperimentalAuth: add missing return false/true for event listeners
shaunanoordin 18cb311
Remove unnecessary PJC code references
shaunanoordin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
<!DOCTYPE html> | ||
<html> | ||
<head> | ||
<meta charset="utf-8"> | ||
<title>Panoptes.js dev app</title> | ||
<style> | ||
* { | ||
box-sizing: border-box; | ||
} | ||
|
||
html, body { | ||
margin: 0; | ||
padding: 0; | ||
} | ||
|
||
#message { | ||
padding: 1em; | ||
background: #c0c0c0; | ||
} | ||
</style> | ||
</head> | ||
<body> | ||
<h1>Panoptes.js dev app</h1> | ||
<form | ||
id="login-form" | ||
method="POST" | ||
> | ||
<input type="text" name="login" /> | ||
<br> | ||
<input type="password" name="password" /> | ||
<br> | ||
<button type="submit">Login</button> | ||
</form> | ||
<pre id="message" /> | ||
</body> | ||
</html> |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { signIn, addEventListener } from '@src/experimental-auth.js' | ||
|
||
class App { | ||
constructor () { | ||
this.html = { | ||
loginForm: document.getElementById('login-form'), | ||
message: document.getElementById('message'), | ||
} | ||
|
||
this.html.loginForm.addEventListener('submit', this.loginForm_onSubmit.bind(this)) | ||
addEventListener('change', this.onAuthChange) | ||
} | ||
|
||
async loginForm_onSubmit (e) { | ||
const formData = new FormData(e.target) | ||
e.preventDefault() | ||
|
||
try { | ||
// Note: await is necessary to catch sign in errors. | ||
const user = await signIn(formData.get('login'), formData.get('password')) | ||
if (user) { | ||
this.html.message.innerHTML += `> Logged in as ${user.login}\n` | ||
} else { | ||
throw new Error('No user?') | ||
} | ||
} catch (err) { | ||
console.error(err) | ||
this.html.message.innerHTML += `> ${err.toString()}\n` | ||
} | ||
return false | ||
} | ||
|
||
onAuthChange (e) { | ||
console.log('+++ Yahoo! We detected a change of user: ', e) | ||
} | ||
} | ||
|
||
function init () { | ||
new App() | ||
} | ||
|
||
window.onload = init |
This file contains 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 file contains 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 file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,278 @@ | ||
/* | ||
Experimental Auth Client | ||
Based on PJC: https://github.com/zooniverse/panoptes-javascript-client/blob/8157794/lib/auth.js | ||
*/ | ||
|
||
const globalStore = { | ||
eventListeners: {}, | ||
userData: null, | ||
bearerToken: '', | ||
bearerTokenExpiry: NaN, | ||
refreshToken: '', | ||
} | ||
|
||
const PANOPTES_HEADERS = { // the Panoptes API requires specific HTTP headers | ||
'Content-Type': 'application/json', | ||
'Accept': 'application/json', | ||
} | ||
|
||
/* | ||
Add an event listener. | ||
|
||
Input: | ||
- eventType: (string) event type, e.g. "change". | ||
❗️ TODO: list event types. | ||
- listener: (function) function that will be called when event is broadcasted. | ||
- _store: (optional) data store. See default globalStore. | ||
Output: | ||
- true if an event listener is successfully added, false otherwise. | ||
Side Effects: | ||
- on success, _store's eventListeners will be updated. | ||
Notes: | ||
- Invalid input is ignored. If no listener or no eventType is specified, | ||
nothing happens. | ||
- Duplicates are ignored. If the same listener is added to the same event, | ||
nothing happens. | ||
*/ | ||
function addEventListener (eventType, listener, _store) { | ||
console.log('+++ experimental auth client: addEventListener()') | ||
|
||
const store = _store || globalStore | ||
if (!eventType || !listener) { | ||
console.log('Panoptes.js auth.addEventListener(): requires event type (string) and listener (callback function).') | ||
return false | ||
} | ||
|
||
// Select array of listeners for specific event type. Create one if it doesn't already exist. | ||
if (!store.eventListeners[eventType]) store.eventListeners[eventType] = [] | ||
const listenersForEventType = store.eventListeners[eventType] | ||
|
||
// Add the callback function to the list of listeners, if it's not already on the list. | ||
if (!listenersForEventType.find(l => l === listener)) { | ||
listenersForEventType.push(listener) | ||
return true | ||
} else { | ||
console.log(`Panoptes.js auth.addEventListener(): listener already exists for event type '${eventType}'.`) | ||
return false | ||
} | ||
} | ||
|
||
/* | ||
Remove an event listener. | ||
|
||
Input: | ||
- eventType: (string) event type, e.g. "change". | ||
❗️ TODO: list event types. | ||
- listener: (function) function that would have been called when event is | ||
broadcasted. | ||
- _store: (optional) data store. See default globalStore. | ||
Output: | ||
- true if an event listener is successfully removed, false otherwise. | ||
Side Effects: | ||
- on success, _store's eventListeners will be updated. | ||
Possible Errors: n/a | ||
Notes: | ||
- Attempts to remove non-existent listener are ignored. | ||
*/ | ||
function removeEventListener (eventType, listener, _store) { | ||
const store = _store || globalStore | ||
console.log('+++ experimental auth client: removeEventListener()') | ||
|
||
// Check if the listener has already been registered for the event type. | ||
if (!store.eventListeners[eventType] || !store.eventListeners[eventType]?.find(l => l === listener)) { | ||
console.log(`Panoptes.js addEventListener: listener for event type '${eventType}' hasn't been registered.`) | ||
return false | ||
} | ||
|
||
// Remove the listener for that event type. | ||
store.eventListeners[eventType] = store.eventListeners[eventType].filter(l => l !== listener) | ||
return true | ||
} | ||
|
||
/* | ||
Broadcast an event to subscribed listeners. | ||
|
||
Input: | ||
- eventType: (string) event type, e.g. "change". | ||
❗️ TODO: list event types. | ||
- args: (anything) arguments to be broadcast with event. | ||
- _store: (optional) data store. See default globalStore. | ||
Output: n/a | ||
*/ | ||
function _broadcastEvent (eventType, args, _store) { | ||
const store = _store || globalStore | ||
store.eventListeners?.[eventType]?.forEach(listener => { | ||
listener(args) | ||
}) | ||
} | ||
|
||
/* | ||
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 | ||
(including their access tokens). | ||
|
||
Input: | ||
- login: (string) user's login, e.g. "zootester1" | ||
- password: (string) user's password | ||
- _store: (optional) data store. See default globalStore. | ||
Output: | ||
- (object) Panoptes User resource. | ||
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: | ||
- standard (expected) API errors: no login/password; or incorrect login/password. | ||
- Uncategorised network errors. | ||
- Extremely unlikely API errors: invalid CSRF tokens, etc. Don't worry about these. | ||
*/ | ||
async function signIn (login, password, _store) { | ||
const store = _store || globalStore | ||
console.log('+++ experimental auth client: signIn() ', login, password) | ||
|
||
// Here's how to sign in to Panoptes! | ||
|
||
try { | ||
|
||
// Step 1: get a CSRF token. | ||
// - The CSRF token (or rather, the anti-cross-site request forgery token) is a | ||
// unique, one-off, time-sensitive token. Kinda like the time-based OTPs | ||
// provided by apps like the Google Authenticator. | ||
// - 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', | ||
headers: PANOPTES_HEADERS, | ||
}) | ||
const response1 = await fetch(request1) | ||
const csrfToken = response1?.headers.get('x-csrf-token') // The CSRF Token is in the response header | ||
// Note: we don't actually care about the response body, which happens to be blank. | ||
|
||
// Step 2: submit the login details. | ||
// - IMPORTANT: at this point, Panoptes should be attaching (HTTP-only) | ||
// cookies (_Panoptes_session and remember_user_token) to its responses. | ||
// - 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 | ||
// 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. | ||
// - HTTP-only cookies are automagically handled by the web browser and the | ||
// server. | ||
// - (If you're curious about the mechanics, check the first response headers' | ||
// "Set-Cookie" values, and the following second request header's "Cookie" | ||
// values.) | ||
// - Our only control as front-end devs is to specify the fetch() of Request()'s | ||
// `credentials` option to either "omit" (bad idea for us), "same-origin" | ||
// (the default), or "include" (when you need things to work cross-origin) | ||
// - ❗️ That `credentials: "include"` option is probably important if we need | ||
// Panoptes.JS to work on non-*.zooniverse.org domains! | ||
|
||
const request2 = new Request(`https://panoptes-staging.zooniverse.org/users/sign_in`, { | ||
body: JSON.stringify({ | ||
authenticity_token: csrfToken, | ||
user: { | ||
login, | ||
password, | ||
remember_me: true, | ||
}, | ||
}), | ||
credentials: 'include', | ||
method: 'POST', | ||
headers: PANOPTES_HEADERS, | ||
}) | ||
const response2 = await fetch(request2) | ||
|
||
// Extract data and check for errors. | ||
// NOTE: this is way more thorough than necessary. | ||
if (!response2.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?') | ||
} | ||
|
||
// Possible responses from /users/sign_in: | ||
// 1. valid login & password => 200 with User resource | ||
// 2. blank login / password => 401 with { error: "You need to sign in or sign up before continuing." } | ||
// 3. invalid login / password => 401 with { error: "Invalid email or password." } | ||
// 4. invalid authenticity token => see https://github.com/zooniverse/operations/issues/561 | ||
// 5. already logged in => 200 with User resource of the previously logged-in user(!) This means if Panoptes thinks you're already logged in (see notes on http-only cookies), then any subsequent login attempts are ignored. | ||
// X. Unexpected error, e.g. network down. | ||
|
||
// Note: old PJC doesn't actually care about the response body, which is the | ||
// logged-in user's User resource. This is because PJC has a separate call for | ||
// fetching the user resource, which is, uh, kinda extra work, but keeps the | ||
// calls standardised I guess? | ||
|
||
// Step 3: get the bearer token. | ||
|
||
const request3 = 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 response3 = await fetch(request3) | ||
|
||
// Extract data and check for errors. | ||
if (!response3.ok) { | ||
const jsonData3 = await response3.json() | ||
const error = jsonData3?.error || 'No idea what went wrong; no specific error message detected.' | ||
throw new Error(`Error from API. ${error}`) | ||
} | ||
const jsonData3 = await response3.json() | ||
const bearerToken = jsonData3?.access_token // The bearer token is short-lived | ||
const refreshToken = jsonData3?.refresh_token // The refresh token is used to get new bearer tokens. | ||
const bearerTokenExpiry = Date.now() + (jsonData3?.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 (jsonData3?.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.') | ||
} | ||
|
||
console.log('+++ signIn() Results: ', | ||
'\n\n userData:', userData, | ||
'\n\n bearerToken', bearerToken, | ||
'\n\n bearerTokenExpiry', new Date(bearerTokenExpiry), | ||
'\n\n refreshToken', refreshToken, | ||
) | ||
store.userData = userData | ||
store.bearerToken = bearerToken, | ||
store.bearerTokenExpiry = bearerTokenExpiry | ||
store.refreshToken = refreshToken | ||
_broadcastEvent('change', userData) | ||
|
||
return userData | ||
|
||
} catch (err) { | ||
console.error('Panoptes.js auth.signIn(): ', err) | ||
throw(err) | ||
} | ||
} | ||
|
||
export { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You’ll need to change the package type to |
||
signIn, | ||
addEventListener, | ||
removeEventListener, | ||
} |
This file contains 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
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Apps like Community Catalogue, which use
@zooniverse/panoptes-js
, will need to pass in their own client ID here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Roger's auth client used a pattern like
which might work here too.