Building Collaborative Document Solutions with Connections Docs 2.0 and Box

 

Welcome back!  In previous posts, we explored the new 3rd party support in Connections Docs 2.0 and built a custom module to communicate with SharePoint 2013.  This time, we’ll be integrating with our friends at Box.  What’s nice about building this integration is that it encounters production-level questions:

  • How do I architect a hybrid (cloud and on-prem) solution?
  • How will authentication work (e.g. OAuth2, cookies, SAML)?
  • What’s the best way to initiate a co-editing session?  (e.g. build my own solution or use Box’s extension points)

The result is a solution that behaves like so.

Download Video: MP4

Admittedly, there’s one step I don’t like.  It’s the login to Docs.  I wrote the solution, but there’s one small issue I’m working through.  Once resolved, I’ll update this post.

The steps you see in the video have the following sequence.

Box Architecture

  1. User begins in Box and chooses the file to be edited in Connections Docs.
  2. Box sends the user to the Connections Docs server.  And in doing so, Box provides Docs with needed information like the file ID and the OAuth code.
  3. The user’s browser then connects to the Docs server (you’ll see the address change in the browser).
  4. The Docs application uses the OAuth code to retrieve an OAuth token directly from Box.  This token allows Docs to act on behalf of the Box user (e.g. downloading and uploading the file).
  5. The Docs application connects to my custom code.  This is needed because Box doesn’t yet know how to give the file to Docs.  So my code acts as that bridge.
  6. The file is downloaded from Box and opened in the Docs editor.
  7. On save, the file is then uploaded back to Box.

So what does it take to do all this?

Box SDK

Box has an easy to use Java SDK.  I’ll be using it to do most of the work to download a file, get the meta data, get information about the user, etc.  This actually makes my code minimal.  For example, here’s everything needed to download and upload the file.

 private BoxAPIConnection getApi() {
 String bearer = settings.get("Authorization");
 
 if(bearer != null && !bearer.equals("")) {
 bearer = bearer.substring(bearer.indexOf("Bearer") + 7);
 logger.finest("Using Box access token " + bearer);
 return new BoxAPIConnection(bearer);
 } else {
 logger.severe("No Authorization header found; OAuth not possible");
 }
 
 return null;
 }
 
 @Override
 public void open(String fileId, OutputStream out) throws IOException {
 logger.entering(BoxRepository.class.getName(), "open");
 
 BoxFile boxFile = new BoxFile(getApi(), fileId);
 BoxFile.Info info = boxFile.getInfo();
 
 logger.fine("Retrieved " + fileId + " from Box " + info.getID());
 
 boxFile.download(out);
 
 logger.exiting(BoxRepository.class.getName(), "open");
 }
 
 @Override
 public void save(String fileId, InputStream in) throws IOException {
 logger.entering(BoxRepository.class.getName(), "save");
 
 BoxFile boxFile = new BoxFile(getApi(), fileId);
 boxFile.uploadVersion(in);
 
 logger.exiting(BoxRepository.class.getName(), "save");
 }
 
 @Override
 public JSONObject getMeta(String fileId) {
 logger.entering(BoxRepository.class.getName(), "getMeta");
 
 JSONObject o = new JSONObject();
 
 BoxFile boxFile = new BoxFile(getApi(), fileId);
 BoxFile.Info info = boxFile.getInfo();
 
 if (info != null) {
 o.put(DocRepository.ID, fileId);
 o.put(DocRepository.VERSION, info.getVersion().getID());
 // TODO : Confirm works as expected
 if (info.getExtension() != null) {
 o.put(DocRepository.MIME, info.getExtension());
 }
 // FIXME : What if the extension is not present in name
 o.put(DocRepository.NAME, info.getName());
 o.put(DocRepository.DESCRIPTION, info.getDescription());
 o.put(DocRepository.SIZE, info.getSize());
 o.put(DocRepository.CREATED,
 DocRepositoryUtil.formatTime(info.getContentCreatedAt().getTime()));
 o.put(DocRepository.MODIFIED,
 DocRepositoryUtil.formatTime(info.getContentModifiedAt().getTime()));
 
 // created_by
 JSONObject c = new JSONObject();
 c.put(DocRepository.ID, info.getCreatedBy().getID());
 c.put(DocRepository.NAME, info.getCreatedBy().getName());
 c.put(DocRepository.EMAIL, info.getCreatedBy().getLogin());
 o.put(DocRepository.CREATED_BY, c);
 
 // modified_by
 JSONObject m = new JSONObject();
 m.put(DocRepository.ID, info.getModifiedBy().getID());
 m.put(DocRepository.NAME, info.getModifiedBy().getName());
 m.put(DocRepository.EMAIL, info.getModifiedBy().getLogin());
 o.put(DocRepository.MODIFIED_BY, m);
 
 // permissions
 JSONObject p = new JSONObject();
 EnumSet<Permission> permissions= info.getPermissions();
 
 if(permissions != null){
 p.put(DocRepository.READ,
 String.valueOf(permissions.contains(
 Permission.CAN_DOWNLOAD))); // String not bool!
 p.put(DocRepository.WRITE,
 String.valueOf(permissions.contains(
 Permission.CAN_UPLOAD))); // String not bool!
 } else {
 // FIXME : does this mean it's not shared or it's owned
 p.put(DocRepository.READ, "true");
 p.put(DocRepository.WRITE, "true");
 }
 
 o.put(DocRepository.PERMISSIONS, p);
 }
 
 logger.exiting(BoxRepository.class.getName(), "getMeta");
 
 return o;
 }

To use the SDK, I’ve placed the box-java-sdk-2.0.0 and it’s maven dependencies inside F:\IBM\Docs\WebSphere\AppServer\lib\ext.  The ext folder is part of WebSphere’s classloader lookup.  Since the various JARs are ~4MB, you can place them in the ext folder rather than inside your web application.

OAuth2

You may have noticed that in my getApi() function, I’m using the OAuth header.  This is my authentication mechanism.  Box tells Docs who the user is by way of the OAuth code.  Docs will then exchange the code for a token, and that token is used in my code.  You also saw me login to Docs.  I think this is unnecessary, and I’m currently doing it because I’m having a different issue.  Once I resolve it, the OAuth token is the user’s identity and Docs will use it to retrieve additional user information from Box.

Box OAuth

Box has a good write up on configuring OAuth on their end.  Read it here.  After setting up my application on Box, I need to get the OAuth information to configure Docs.

Box OAuth

Box assigns the client_id and client_secret for you.  Add the URL to Docs in the redirect_uri, for example https://docs.demos.ibm.com/docs/driverscallback.  One point of caution, just be consistent when entering this URL in various places.  OAuth will fail if the redirect_uri here is different than the one Docs sends.  Check your concord-config.json file for entries like

"docs_callback_endpoint" : "https://docs.demos.ibm.com/docs/driverscallback"

Whatever you have here should match the redirect_uri (or vice versa).

Docs OAuth

Next we need to configure Docs to use the client_id and client_secret values.  See the article here on doing so.  Admittedly, I did not even get this right.  Here are the examples from IBM.

./wsadmin.sh -lang jython -user xx -password xx -f customer_credential_mgr.py -action
add -customer docs.demos.ibm.com -key oauth2_client_id -value
"l7xxf61984f99f404575a781d47c6bfebdca"
./wsadmin.sh -lang jython -user xx -password xx -f customer_credential_mgr.py -action
add -customer docs.demos.ibm.com -key oauth2_client_secret -value
"cc692ce34451418e86d9b231ee34af65"

Some helpful points:

  • The value used for customer should match the “customer_id” : “docs.demos.ibm.com” entries in concord-config.json and viewer-config.json.
  • You will issue two wsadmin commands to store oauth_client_id and oauth2_client_secret.  In the beta, I think this was documented differently.
  • The wsadmin commands are storing data in CONCORDDB.CUSTOMER_CREDENTIAL should you need to verify.

You’ll also need to confirm that concord-config.json and viewer-config.json are properly set to use OAuth.  This is done in the following sections (concord-config.json on DMgr01 as the example).

{
 "id" : "external.rest",
 "class" : "com.ibm.docs.repository.external.rest.ExternalRestRepository",
 "config" :
 {
 "s2s_method" : "oauth2",
 "customer_id" : "docs.demos.ibm.com",
 "oauth2_endpoint" : "https://app.box.com/api/oauth2/token",
 "j2c_alias" : "",
 "s2s_token" : "123456789",
 "token_key" : "docstoken",
 "onbehalf_header" : "docs-user", 
 "media_meta_url" : "http://docs.demos.ibm.com/mydocs/DocServlet?id={ID}&mode=meta",
 "media_get_url" : "http://docs.demos.ibm.com/mydocs/DocServlet?id={ID}&mode=content",
 "media_set_url" : "http://docs.demos.ibm.com/mydocs/DocServlet?id={ID}&mode=content",
 "docs_callback_endpoint" : "https://docs.demos.ibm.com/docs/driverscallback",
 "repository_home" : "http://docs.demos.ibm.com/mydocs/DocServlet"
 }
 }
{
 "id" : "external.rest",
 "class":"com.ibm.docs.authentication.filters.ExternalAuth",
 "config" : {
 "s2s_method" : "oauth2" 
 } 
 }
{
 "id" : "external.rest",
 "class":"com.ibm.docs.directory.external.ExternalDirectory",
 "config" : {
 "profiles_url" : "",
 "s2s_method" : "oauth2",
 "customer_id" : "docs.demos.ibm.com",
 "oauth2_endpoint" : "https://app.box.com/api/oauth2/token",
 "j2c_alias" : "",
 "s2s_token" : "123456789",
 "token_key" : "docstoken",
 "onbehalf_header" : "docs-user", 
 "docs_callback_endpoint" : "https://docs.demos.ibm.com/docs/driverscallback",
 "keys" : {
 "id_key" : "id",
 "name_key" : "name",
 "display_name_key" : "display_name",
 "email_key" : "email",
 "photo_url_key" : "photo_url",
 "org_id_key" : "org_id",
 "url_query_key" : "userid"
 },
 "bypass_sso" : "false" 
 } 
 }

Note that the oauth2_endpoint is https://app.box.com/api/oauth2/token.  This is the second step in the OAuth dance – not the first.

SSL

I’ve used SSL (https) in a few places – most notably for the Box OAuth endpoint.  Docs (i.e. WebSphere) will be going out to Box to retrieve the OAuth code.  To do that over SSL, we must import the SSL certs into WebSphere.  We also need to do this because as you see in the architecture diagram, the Box SDK needs to connect to api.box.com and upload.box.come over SSL.  If you do not do this, you will see the SSL handshake exceptions in the log.

The steps to do so are documented in various places, for example here.  I’ve done the import into the Cell Default Trust store for app.box.com, upload.box.com, and api.box.com.  Just do all three for best practice – today Box is using the same cert for two of their servers, which is why you only see two certs listed in my trust store.

Box SSL

Box SSL 2

X-FRAME-OPTIONS

OMG there’s more?  Almost done.  But this one really confounded me.  Box is going to open the Docs app inside an IFrame.  For that to work correctly, we need certain security settings applied in the Docs HTML response.  I knew why the problem existed (the developer console in Firefox or Chrome will tell you it’s an issue), but I didn’t know how to resolve.  Basically, we need to change the X-FRAME-OPTIONS: SAMEORIGIN header to be ALLOW-FROM https://app.box.com.  All those blog posts that say you can do this with an Apache unset header are wrong.  It just didn’t work.  Fortuntely (after alot of decompiling), I found an undocumented way.  Add the following before the last brace at the end of your concord-config.json.

 "x-frame-options" :
 {
 "allow_option" : "ALLOW-FROM",
 "allow_uri" : "https://app.box.com"
 }

This will ensure that the header is set from the Docs application code.

Restart Docs and test.

This example was not easy.  So virtual high five for me, and if you run into issues, post a comment.

Happy coding …

Leave a Reply

Your email address will not be published.