Proposal: Giving control of session state to app developers


#1

When we first designed the API for blockstack.js, we made it so that state related to a user signing in to a Blockstack app was automatically stored to and read from the browser’s localStorage key-value store.

While this made it super easy to get started, it made things confusing for developers who didn’t realize that this was happening. Reliance on APIs only available in the browser also have using this in non-browser environments such as node problematic. It also makes it difficult to use the library in apps such as the Browser or a server app that generate their own private keys and configuration data outside of the normal Sign in With Blockstack process.

Below is pseudo code that outlines my proposal to address this issue.

I’d love your feedback on this. I hope to get started on this early next week.

New Classes

Below are some pseudocode signatures for new classes.

// modeled after what we have in Android https://github.com/blockstack/blockstack-android/blob/d70864acdc12f50bd90fb276853c523845e04e22/blockstack-sdk/src/main/java/org/blockstack/android/sdk/BlockstackConfig.kt#L14
class AppConfig {
  appDomain: string
  scopes: Array<string> = DEFAULT_SCOPE,
  redirectPath: string = '/redirect', // this needs to be on appDomain so only accept paths
  manifestPath: string = '/manifest.json',  // this needs to be on appDomain so only accept paths
  coreNode: string = null // if null, use node passed by auth token v1.3 or otherwise core.blockstack.org
}

class BlockstackSession { 
  appConfig: ?AppConfig
  transitKey: ?string
  appPrivateKey: ?string
  username: ?string
  identityAddress: ?string
  coreNode: ?string
  gaiaHubConfig: ?GaiaHubConfig
  version: string

  constructor({ appPrivateKey, username, coreNode, hubUrl  }) // used by apps like that generate their own keys
  {}
}

// existing functionality would be exposed through this class
class Blockstack {
  constructor( { appConfig } ) {}
  constructor( { session } ) {}
}

Usage in a web app

This is how using this in a typical web app might look.

Initiating sign in

import Blockstack, { AppConfig } from 'blockstack'

const myAppDomain = window.location.origin

const appConfig = new AppConfig(myAppDomain)

const blockstack = new Blockstack({ appConfig })

const session: BlockstackSession = blockstack.getSession()

/*
  session.transitKey is an ephermal key
  everything else but version is null
 */


// app developer manages persistance of session state
localStorage.set('my-cool-app-session', session)

blockstack.redirectToSignIn()

Handling sign in response

The user approves or rejects the sign in request in his authenticator (eg. the Blockstack browser) and is redirected to a page hosted on the app with the following code:

import Blockstack from 'blockstack'

// app developer is responsible for retrieving session state 
const session: BlockstackSession = localStorage.get('my-cool-app-session')

const blockstack = new Blockstack({ session }) 

if (blockstack.isSignInPending()) {

  blockstack.handleSignIn(authResponseToken: string = getAuthResponseToken())
  .then(session: BlockstackSession => {
    // do custom app stuff when sign in succeeds
    
    // app developer should update their persisted copy
    // of session state
    localStorage.set('my-cool-app-session', session)
  })
  .catch(error => {
    // user didn't approve sign in 
    // or other error occurred
  })
}

const user: User = blockstack.getUser()

Using this in an authenticator or node app

Some apps, like the Blockstack Browser, the unofficial web extension and other authenticator type apps generate their own keys in a process that doesn’t involve the typical authentication process.

Here’s how that might look:

import Blockstack, { BlockstackSession } from 'blockstack'

const identityKey = 'abc'
const options = { appPrivateKey: identityKey, username, coreNode, hubUrl }

const session = new BlockstackSession(options)
const blockstack = new Blockstack({ session })

blockstack.putProfile(profile)
blockstack.putFile('avatar.jpg', photo, { encrypt: false })

Tracking issue for this is: https://github.com/blockstack/blockstack.js/issues/531


2018-08-29 Engineering Meeting
Blockstack Weekly Update - August 27-31, 2018
#2

I like this a lot! My only point of perhaps contention comes in at the end, in the suggestion of how to use this in an authenticator app. In my opinion, authenticator apps and blockstack apps are sufficiently different from one another that sharing an SDK is really awkward at times. The profile editing, and keychain generation routines of the authenticator seem really out of place for an app SDK, and likewise, the user session redirection code seems out of place for an authenticator. I could be convinced otherwise, simply because putting everything in one library is a little easier, but it seems like future design decisions could exacerbate this.


#3

In the past, we’ve talked about the Blockstack Browser as a special type of Blockstack App in the same way that a server-side app or server-only app are special types of Blockstack Apps.

On one hand, I agree sharing an SDK can be at times really awkward. On the other hand, it seems like profile editing code belongs together with profile reading code and should use the same code paths that other apps use to write files instead of being a special case.

I see this as an intermediate step towards splitting up the library into multiple modules as suggested by @wbobeirne

Perhaps what would make sense at that time would be to further break split out authenticator specific code in modules that wouldn’t need to be included in most apps? Will’s proposal already does that with the blockstack-wallet module - this keep keychain generation out of non-authenticator type apps.

Once there are multiple modules, I’d defer to the teams building authenticator type apps as to which approach makes more sense for their apps.


#4

I’m definitely in favor of getting blockstack.js to truly run everywhere without shimming browser libraries. However, it’d be great if we could do so without breaking changes to the API.

One technique I’ve considered for this is using something like passport.js strategies, but for blockstack data storage. The default strategy would be detected based on environment (So local storage for the browser) but you’d be able to define your own. So they’d look something like this:

// Example of strategies
abstract class StorageStrategy {
  getItem(key: string): Promise<any>;
  setItem(key: string, item: string): Promise<boolean>;
}

class LocalStorageStrategy implements StorageStrategy {
  getItem(key) {
    return Promise.resolve(localStorage.getItem(key))
  }
  setItem(key, item) {
    localStorage.setItem(key, item)
    return Promise.resolve(true)
  }
}

class DatabaseStorageStrategy implements StorageStrategy {
  getItem(key) {
    return db.query(`SELECT * FROM storage WHERE key="${key}"`)
      .then((res) => res.data)
  }
  setItem(key, item) {
    return db.query(`INSERT INTO storage(key, data) VALUES ("${key}", "${data}")`)
      .then(() => true)
  }
}

and then in use with a node app that uses a database, it would look like

import { setStorageStrategy, DatabaseStorageStrategy } from 'blockstack'
setStorageStrategy(DatabaseStorageStrategy)
// ... Everything else as normal

However, I think your proposal likely leads to simpler API functions that don’t require us to repeat passing so much data, has more understandable state between function calls (by storing state as properties on the Blockstack class instance,) and will probably be easier to build off in the future.


#5

While I like the original proposal for it’s simplicity, I would have to lean towards @wbobeirne simply because we have to know when the state (within the storage) has to be updated!

Otherwise, if we were to keep the Original Proposal then there’d have to be get/set callbacks or something like an update callback within the AppConfig like so:

class AppConfig {
  // ...
  /**
   * If `value` is null, delete, otherwise, set to item of index `key` the new `value`
   * @type {(state: BlockstackSession, key: string, value?: any) => Promise<void>}
  **/
  updateSession: null
}

I think that breaking changes are fine, but would be more “palatable” if we also implemented the code splitting change within the same update. However that makes the task very big and slows down implementing this to a full stop, so I really don’t know.

Otherwise, I love the simplicity of the api of putProfile and putFile… it isn’t fun to do this instead, as I have to right now…


#6

I really like Will’s idea of having storage strategies that default based on the environment.


#7

Besides using them for mocking, are there any desirable storage strategies that are not Gaia? Serious question.


#8

These are storage strategies for the session state, not for app data. We currently use localStorage


#9

If we’re moving ahead with this proposal, can I request that we make the authenticator URL configurable in the BlockstackSession object?

This would make it possible to build automated browser tests for the auth flow by redirecting auth to a deploy preview build. And this also enable people to build their own authenticators in the future.

class BlockstackSession { 
   ...
  authenticatorURL: ?string
}

#10

I think this makes a lot of sense.


It’s actually already configurable in our shipped versions here too:

export function redirectToSignInWithAuthRequest(authRequest: string = makeAuthRequest(),
                                                blockstackIDHost: string =
                                                DEFAULT_BLOCKSTACK_HOST)

#11

I don’t like the proposed API. This should be as simple as possible with few steps.

I was putting off replying because I wanted to come up with an actual alternative. But the gist of my idea was to actually split into “high level API” and “low level API”. Where some functionality does not exist from the high level API (like if you want to do custom things), but most does.

Will’s idea of using the existing API sounds good, because it already exists. The default Storage strategy selection can be done based on whether it runs in browser or Node. That sounds like a common enough thing to make easy. Or possible better (at least at first until we see how it’s used) just default to LocalStorage (browser), and make People use the low level API for setting another storage provider.

I’m basically thinking along the lines of how Python requests work, where it gives you a standard Session if you just use it. But allows you to custom-create your own session if you want. See how request.get is implemented (tiny bit simplified);

# call: request.get(args)
s = request.Session()
return s.get(...args)

If you ever wanted to do something advanced, you’d just do that on your own:

mysess = request.Session(lots=of, options=Here())
response = mysess.get(...)  # yes, session also has the developer friendly .get/.post helpers

Sorry I never got a clear idea how that would look for the Blockstack API. But I think it’d be a mix between Larry and Will’s API.


#12

Agree - Work on this is on a bit of hold at the moment while I work on some other things. I’m leaning towards merging the classes I Blockstack and BlockstackSession into one class. An instance of that class represents a logged user session. It could be called Session or User or Identity or something to that effect.

I’m not proposing making any changes to the various public API methods except that some would become instance methods.

I really like Will’s storage strategy proposal as well.


#13

I do think you could make it a little simpler by creating a new “Blockstack” object via

const blockstack = new Blockstack({
  appConfig: { appDomain: myAppDomain },
  sessionConfig: { authenticatorUrl: 'localhost:8700' }
});

and then just use some Object.assign's to assign defaults, etc. (do note it’s not recursive so you’d have to do some for..in stuff).

In addition, instead of passing the session within each action (I think that’s pretty complicated), here’s an example for using a callback instead:

// web / localstorage
const onSessionUpdate = (state, key, value) => {
  if(!state) window.localStorage.clear();
  else if(!key) Object.keys(state).forEach(k => window.localStorage.setItem(k, state[k]);
  else if(!value) window.localStorage.removeItem(key);
  else window.localstorage.setItem(key, value);
};
// custom rethinkdb wrapper
const onSessionUpdate = (state, key, value) => {
  if(!state) db.run(stateTable.delete());
  else if(!key) db.run(stateTable.replace(state));
  else if(!value) db.run(stateTable.get(key).delete());
  else {
    const obj = {};
    obj[key] = value;
    db.run(stateTable.insert(obj, { conflict: 'update' }));
  }
};

And then using them in the config…

const blockstack = new Blockstack({
  appConfig: { appDomain: 'my-domain' },
  sessionConfig: { updateCallback: onSessionUpdate }
});

But outside of those specifics, the biggest difference I see between the Original Proposal and Will’s would be the former uses the storage provider as something to backup and recover the state, whereas the latter uses it as a complete storage solution. I feel like the former is a little easier in regards to speed and ease-of-use (for the library at least), but the latter has better persistence in regards to crashing and unhandled closing of the application. You could also use the latter along with flux/redux/vuex instead of localstorage as well.

Regardless, with either being done there should be a default driver to use localstorage, and it would have to be overidden to use a custom storage backend (like localForage or a db).


Until either solution gets implemented though, here’s a quick fix for those who are running on node currently:


#14

Thanks to everyone for your feedback!

I’ve opened a pull request that implements a lot of was discussed in this topic.

It moves user-session related calls into the instance of an object UserSession. Data related to a given user-session is encapsulated in this instance and persisted by an instance of a object that extends SessionDataStore.

Defaults such as app domain, etc continue to be auto-detected when run in a browser environment.

This means the library can continue to be used with minimal configuration in a web browser environment:

import { UserSession } from 'blockstack'

const userSession = new UserSession()

userSession.redirectToSignIn()

userSession.getFile('file.json')

In a web browser, by default UserSession will store session data in the browser’s localStorage through an instance of LocalStorageStore.

This means that app developers can leave management of session state to users.

In a non-web browser environment, it is necessary to pass in an instance of AppConfig which defines the parameters of the current app.

  import { AppConfig, UserSession } from 'blockstack'
  const appConfig = new AppConfig('http://localhost:3000')
  const userSession = new UserSession({ appConfig })

In non-browser environments (ie. node), session state is stored in the instance using the InstanceDataStore. If a developer wishes to store this elsewhere, he should can extend SessionDataStore and implement his own storage strategy.

You can test this by linking it to this branch of the blockstack todos app: https://github.com/blockstack/blockstack-todos/tree/feature/blockstack-19

Here are screenshots of docs for the UserSession API:


The pull request requires additional testing, test coverage and documentation before merging. I wanted to share it earlier rather than later to give everyone an opportunity to share your thoughts. I’d love your feedback either here or in the pull request!


#15

Looks good to me, @larry! A couple quick questions:

  • Is this PR intended to be backwards-compatible with master, or will existing apps need to be patched to use UserSession and AppConfig objects?

  • I see that the UserSession object also contains the storage API. Do we want to continue including storage and authentication methods in the same facade object, or would this be a good opportunity to begin splitting them out (i.e. pending the development of gaia.js)?

  • To be clear, the new UserSession and AppConfig objects remove all dependencies on globals, right? i.e. Will it be safe to instantiate and use multiple UserSession and AppConfig objects in the same program?