Notice: The WebPlatform project, supported by various stewards between 2012 and 2015, has been discontinued. This site is now available on github.

How we implemented SSO

The objective of this document is to describe how we implemented a SSO solution for WebPlatform.org. It is meant to give an idea of the various moving parts.

If you want to see the high level description, refer to WPD/Projects/SSO/Login Workflows or if you want a shorter version, go to WPD/Projects/SSO/OAuth2_handshake_process_in_a_few_cURL_calls.

The authentication portal is using our own fork of Mozilla Firefox Accounts (“FxA”) deployed on WebPlatform.org infrastructure. Details of the adaptations are described in WPD/Projects/SSO/Adapt_Firefox_Accounts_for_WebPlatform.

Stories

The present document describe how we address a set of user stories but it should be noted that the final result is expected to use the SSO across ALL WebPlatform.org services and participating W3C Specifications.

Workflows

Repeating what was described in WPD/Projects/SSO/Login Workflows, to be detailed in this page.

In order to fulfill the given #Stories, we needed to implement a SSO solution covers the following:


Delegating authentication

This step is meant to address the Get authenticated through a single authority workflow.

0. Preamble

In this implementation we are passing through authentication to get an OAuth2 Bearer authorization token so that we can get data on the behalf of the authenticated user.

The following makes use of the generated Authorization token to read a profile server and read associated user data (i.e. email address, full name, username, etc.) that will be used in all web applications.

NOTE At the moment, the Authorization token is used only once. In future development, we might want to add features and will need to store securely that token to make other actions on the behalf of the user.

1. Configure the OAuth server to accept requests from a client

In OAuth terminology, a client is a consumer that is authorized to rely on the OAuth server. A client can be a web application, but its not only limited to that.

In this example, we are going to show how the WebPlatform Docs “test” wiki (“C”) gets data from the various layers.

Client configuration is described in the project documentation available in the WebPlatform’s fork of FxA OAuth Server in docs/clients.md.

Within FxA OAuth server configuration, a client: [/* array of clients */] entry looks like this:

{
  "id": "7e7e11299d95d789",
  "secret": "a331e8a8f3e553a430d7e5b904c6132b2722633af9f03128029201d24a97f2aa",
  "name": "WebPlatform Test",
  "image_uri": "...",
  "redirectUri":"/docs/test/Special:AccountsHandler/callback",
  "whitelisted": true
}
  • id: Is a 8 byte hexadecimal string that you will need to have on the client configuration
  • secret: Is a 32 byte hexadecimal string that you will also need on the client configuration.
  • redirectUri: Is where you should send the users to when they successfully authenticated.

To continue with our example, we are connecting to a MediaWiki installation that has the WPD/Projects/SSO/MediaWikiExtension available. In the case of that particular extension, it expects that we send authenticated users back to Special:AccountsHandler/callback.

2. Configure client

Configuring a client to use our OAuth server can be different depending on how the extension implements OAuth2.

Regardless of the exact configuration lines or code, every OAuth2 clients has similar configuration lines.

Such as:

  • Start the process
  • Generate an Authorization token

In the case of our implementation, we also added (fxa_profile) so that we can read user data from somewhere.

Our MediaWiki extension has similar configuration in its Settings.php file:

require_once( "$IP/extensions/WebPlatformAuth/WebPlatformAuth.php" );
$wgWebPlatformAuth['client']['id']             = '7e7e11299d95d789';
$wgWebPlatformAuth['client']['secret']         = 'a331e8a8f3e553a430d7e5b904c6132b2722633af9f03128029201d24a97f2aa';
$wgWebPlatformAuth['endpoints']['fxa_oauth']   = 'https://oauth.accounts.webplatform.org/v1/';
$wgWebPlatformAuth['endpoints']['fxa_profile'] = 'https://profile.accounts.webplatform.org/v1/';
$wgWebPlatformAuth['methods']['authorize']     = 'authorization';
$wgWebPlatformAuth['methods']['token']         = 'token';

While configuration can be different depending on the client extension, a few endpoints are required to be used in our installation.

  • GET https://oauth.accounts.webplatform.org/v1/authorization
  • POST https://oauth.accounts.webplatform.org/v1/authorization
  • POST https://oauth.accounts.webplatform.org/v1/token
  • GET https://profile.accounts.webplatform.org/v1/session/read, with scope 'session'

Most of these requests aren’t made through the browser but through a server-side HTTP client. In the MediaWiki extension, we are using Guzzle.

NOTE Even though the calls are aimed at endpoints under SSL, it feels safer to have those requests made outside of the reach of the visitor web browser.

3. Possibility: Detected a session

On page load, a non blocking #JavaScript shared module: Detect and start automatically a session attempts to read from the accounts server to see whether a session is already opened or not.

The outline of what’s happening is described in #SSO and remembering.

If no session is detected, the module does nothing.

4. From a page, when you click login

The MediaWiki extension detects the click and sends you to Special:AccountsHandler/start. Its an internal process to generate a redirect to the OAuth2 authentication flow.

The web application generates and sends user to the OAuth authentication endpoint, with some data:

  • The client identifier ("client_id")
  • What the application wants ("scope")
  • What state it was in ("state"), this is a random key used to store inside a key store such as Memcache so we can retrieve the state data after the authentication process.

NOTE In Mozilla Firefox Accounts, the callbackUri is configured in the server configuration directly. This is considered safer to trust only the configuration file in contrast to common OAuth2 server implementation where they also want the callbackUri as part of the first redirect. This is a way to address a security breach in original OAuth2 specification.

Since the OAuth server knows who is the client, it adjusts the title to "Sign in to WebPlatform Test".

sso steps login dialog.png

NOTE In the screenshot above, you see scope=profile. Since that snapshot, we changed the scope name to session because we needed to differentiate web application that will use OAuth to create local sessions from future use cases.

Once the authentication worked the accounts server sends you to the callbackUri (e.g. Special:AccountsHandler/callback for Media Wiki).

5. Redirected the callback URI

From that callback, we get two keys:

  • state: Is that string we gave in the previous step, we get it back so we can resume where we were before the authentication process. If the state data (e.g. previous page visited) was serialized and sent to Memcache, we can ask it once again and resume.
  • code: This is a one-time token that we can use to get an OAuth2 Token. In this example, its “SOMETHING_LONG”

The callback URI looks like this:

/wiki/Special:AccountsHandler/callback?code=SOMETHING_LONG&state=5a72cd23b1b5feb8

Based on the received data, we can continue with the OAuth2 handshake.

6. Get an Authorization token

From the callbackUri handler, with the expected data (state, code) we make a few HTTP calls server to server (i.e. invisible to the web browser).

The call to get the Authorization token contains:

  • client_id: The identifier we configured in both OAuth server and the client
  • client secret: The shared secret
  • code: The one-time code we got when redirected to the callbackUri.

The request is similar to this cURL call:

curl -XPOST -H 'Content-Type: application/json' \
     'https://oauth.accounts.webplatform.org/v1/token' \
     -d '{"client_id":"7e7e11299d95d789",
          "client_secret":"a331e8a8f3e553a430d7e5b904c6132b2722633af9f03128029201d24a97f2aa",
          "code":"SOMETHING_LONG"}'

We get in exchange the Authorization token in a response that looks like this:

{"access_token":"6243bbcf3f1f451cc5b3f47e662568b90863995a4e675a3073eb72434ab2ba31",
 "token_type":"bearer",
 "scope":"session"}

The access_token is what we needed to act on the behalf of the logged in user.

At the time, the only protected service is the profile server, but we might want to protect other components later down the road.

NOTE An OAuth Authorization token is basically a key to make actions on the behalf of a valid user. In this regard, it is of the utmost importance to have it sent only through SSL, and, ideally, always outside of the reach of the web browser.

7. Finsish off things

Continued in #Read user data, and then #Initialize local web application session workflow.


Read user data

This step is meant to address the Read user data through an API workflow.

Assuming our web application has a valid Authorization token, OR session token, we can get the account data we need from the profile server.

The profile server MUST return the same data regardless of whether it got an Authorization token or a session token.

The Profile server has two distinct methods to provide user data.

  • One to read the user data for the first time, requiring a valid OAuth2 Authorization token
  • One to recover the user data, meaning the server already has a session opened on the accounts server and we want to use the same data.

To read more about recovering a session, refer to #SSO and remembering workflow.

The call to the profile server looks like the following cURL calls:

With an Authorization token:

curl -H 'Content-Type: application/json' \
     -H "Authorization: Bearer 6243bbcf3f1f451cc5b3f47e662568b90863995a4e675a3073eb72434ab2ba31" \
     'https://profile.accounts.webplatform.org/v1/session/read'

With a session token:

NOTE; The sessionToken is available from the Accounts server settings view only once a user is logged in. To access it, we are using cross-frame communication and read the token from localStorage API.

// URI https://accounts.webplatform.org/settings
var obj = JSON.parse(window.localStorage.getItem('__fxa_session')) || {};
console.log(obj); // if there is a session, obj.sessionToken will have what we need

Use the obj.sessionToken after Session in the following cURL:

curl -v -H 'Content-Type: application/json' \
        -H "Authorization: Session 095dc99de03e99c6d93211aced561c6a28800cdd20fe2ff55ef4626401a04045" \
        'https://profile.accounts.webplatform.org/v1/session/recover'

If the profile server accepted either methods, we get a JSON object looking like this:

{"username": "jdoe",
 "fullName": "John Doe",
 "email": "hi@example.org",
 "uid": "3E09D6DF843341BC921A25423AB83BAF" }

We can now start the session in the client web application.

NOTE Authorization headers is functionally the same as what the web browser does with cookies. In fact, during a browsing session that has cookies, they are sent along with every HTTP requests. And that is, regardless of whether its an image, a CSS file, or a web page. The cookie is therefore a way to tell the application server to tell who the visitor is and potentially change the returned resource.


Initialize local web application session

This step is meant to address the handle their local users and session workflow and what the backend code MUST implement.

One of the key behavior in a SSO system is that each configured application relies on an authority to provide common user data (e.g. email, username, fullName), but also to confirm if it can start a session locally and overriding its own user validation system.

0. Preamble

The following MUST be done in the backend code answering at the what is refered to as "callback" (e.g. Special:AccountsHandler/callback) on each relying parties who wants to use the SSO.

It handles the following steps:

This component answers in two different scenarios:

In both Behavior forks, the returned data from the profile server MUST be in the following format:

{"username": "jdoe",
 "fullName": "John Doe",
 "email": "hi@example.org",
 "uid": "3E09D6DF843341BC921A25423AB83BAF" }

Since each web application can have different database and ways to refer to a given user, we are using this information to find if a user already exists in the local database, or we create one with the default details for the user.

Based on the data received from the profile server, we initialize a session locally.

1. Behavior forks

Here is how the backend code MUST behave in both cases.

The steps are basically the same, but acts differently depending of what initiated the process.

Here are the differences in both cases.

1.1 Possibility: Completing an OAuth2 handshake

This section covers what the backend does when it got called during an OAuth2 workflow during #Delegating authentication and mentionned in #5. Redirected the callback URI.

In this case, we are provided with TWO keys through a GET request to our callback (e.g. Special:AccountsHandler/callback)

  • code: The one-time token that is the last step to get an Authorization token
  • state: A key identifier that we got when we saved away in a key store (e.g. Memcache) the state before signing in.
1.1.1 Get an OAuth2 authorization token

With the code in hand, we can #6. Get an Authorization token, then ask the profile server the user data.

As described in #6. Get an Authorization token.

1.1.2 Read data from profile server

As described in #Read user data With an Authorization token (OAuth2).

1.1.3 Find matching user, and/or create a new one

This is specific to each web application, for MediaWiki you can refer to WPD/Projects/SSO/MediaWikiExtension.

1.1.4 Start a session

This is specific to each web application, for MediaWiki you can refer to WPD/Projects/SSO/MediaWikiExtension.

1.1.5 Resume previous state

During previous OAuth2 workflow steps, we saved some data in a key store (“state”) and we can use it to send the user where he was in.

In the case of the WPD/Projects/SSO/MediaWikiExtension, we currently store the previous page the user visited and would look like this:

{return_to:"/docs/WPD/Projects/SSO/Login_Workflows"}

Based on that information, we issue a redirect and the user is back where he was.

1.2 Possibility: Resuming a session confirmed by the accounts server

This section covers what the backend does when we detected a session through the frontend discovery mechanism WPD/Projects/SSO/Login Workflows#Starting a session by communicating with accounts server.

In this case, we are provided with ONE key through a POST request to our callback (e.g. Special:AccountsHandler/callback)

  • recoveryPayload: Containing the data used to ask the profile server

NOTE This step is currently getting directly the sessionToken and will eventually changed, see reasons in WPD/Projects/SSO/Improvements roadmap#Recovering session data.

1.2.1 Make a request with the received data

As described in #SSO and remembering, in the step #4. Read data from the profile server with a recoveryPayload.

1.2.1 Read data from profile server

As described in #Read user data With a session token.

1.2.2 Find matching user, and/or create a new one

This is specific to each web application, for MediaWiki you can refer to WPD/Projects/SSO/MediaWikiExtension.

1.2.3 Start a session

This is specific to each web application, for MediaWiki you can refer to WPD/Projects/SSO/MediaWikiExtension.

1.2.4 Return an HTTP response

This step is required by the #JavaScript shared module: Detect and start automatically a session so it knows whether it should reload or not the page and let the user use the current site as an authenticated user.

  • 204: All went fine, reload!
  • 4xx: Stop there, no valid session was found
  • 5xx: Stop there, an unexpected error happened

If the status is not an error (e.g. 4xx, 5xx), we can send an error message in the response body with a Content-type: text/plain HTTP header.


SSO and remembering

This step is meant to address the Let each configured web applications to detect a session on the authority workflow.

As described in #Stories, the following describes how we can get the same data from #Read user data, but without asking for the user to authenticate again.

To have the system to not ask the user to authenticate again we needed to find a way to check if a session is opened, and get the user data so I can create a local session from that trusted source.

Most of the following would happen at #3. Possibility: Detected a session then #Read user data using a #JavaScript shared module: Detect and start automatically a session that handles the checks and makes the local web application to #Initialize local web application session automatically for the user.

0. Preamble

This is what’s currently implemented. Ideally it should leverage what’s proposed in the previous section. A high level description is available at WPD/Projects/SSO/Login Workflows#Starting a session by communicating with accounts server

While this implementation proposal is some sort of workaround, it is designed to provide the same data we would get if the issue WPD/Projects/SSO/Improvements roadmap#Leveraging completely OAuth2.

In either case, we first need to discover whether a session is already opened using Content Security Policies and Cross Frame communication through a hidden iframe via JavaScript.

Story: A person signed in to work on testing a feature in the SSO enabled WebPlatform Docs Staging wiki (“D”). Later in his browsing session, the user wants to add an comment to a W3C spec that supports annotations (“B”). Without having to enter his credentials more than once.

The workflow in this code proposal should be run before #4. From a page, when you click login.

The following is separated in two parts:

1. Sign in to (“D”)

Continuing our scenario, let’s use the WebPlatform Docs Staging wiki (“D”)

Complete signin in process through the accounts server (the OAuth way), and you should get back to the staging wiki with a session opened.

NOTE: Once you passed this step, you can see on the accounts server that it no loger prompts you to authenticate.

2. Go to (“B”), another web app that also uses the accounts server

Continuing our scenario, we will want to add a comment on a W3C spec that supports annotations (“B”)

The following JavaScript happen.

2.1. From B, prepare to handle confirmation

This is where we listen to what we get from the accounts server, validate if a session exists, and trigger the calls to the profile server and handle the returned data.

window.addEventListener("message", function(returned){console.log(returned.data)}, false);

NOTE: This is handled in file #JavaScript shared module: Detect and start automatically a session

2.2. From B, open a iframe to (“C”)

For this to work, we have to make sure that the Content server sends appropriate CSP headers on the FxA content server.

    // See 0.2
    app.use(helmet.csp({"script-src":["'self'", "*.webplatform.org", "*.mroftalpbew.org", "*.global.ssl.fastly.net", "*.w3.org"]}));

Create the iframe

    var authChecker=document.createElement('iframe');authChecker.src='https://accounts.webplatform.org/';authChecker.frameworder=0;authChecker.width=0;authChecker.height=0;authChecker.id='authChecker';document.body.appendChild(authChecker);

NOTE: This is handled in file #JavaScript shared module: Detect and start automatically a session at the

window.sso.init(closure, '/wiki/Special:AccountsHandler/callback');
2.3. From B, Send trigger to ask confirmation from the iframe

Since the iframe has reloaded fully the document and would behave the same as if the user went to https://accounts.webplatform.org/ with an opened session, we can ask that iframe document to send us details of the session.

To do so, we are sending a request for confirmation, like this:

authChecker.contentWindow.postMessage('hi', 'https://accounts.webplatform.org/');

NOTE: This is handled in file #JavaScript shared module: Detect and start automatically a session. The trigger would be lauched from window.sso.doCheck() and handles the next steps until the backend comes in.

3. From B, handle the response from the iframe

Provided a session is already open, we should get something similar to:

{hasSession: true,
 recoveryPayload: "e73f75c00115f45416b121e274fd77b60376ce4084267ed76ce3ec7c0a9f4f1f"}

If there is no session, or in case of failure, we would get:

{hasSession: false,
 recoveryPayload: null}

Since the iframe and the communication would had failed from any non trusted source. If we are from a trusted source as described in 2.2, it is safe to assume that we are a legitimate relying party to the SSO system.

This is where the non blocking #JavaScript shared module: Detect and start automatically a session finish its lifecycle. Upon detecting an object that has hasSession: true AND a 64 character long string for sessionToken, it MUST send an Ajax POST to the local web application callbackUri with the token.

NOTE: This is know to bring a problem. The isussue is that if we use an unaltered sessionToken as the sole proof, anybody could know this and hijack a session on the relying party. This needs to be improved.

4. Read data from the profile server with a recoveryPayload

The following happens at #Initialize local web application session.

This endpoint will be available ONLY through the internal network of WebPlatform and the W3C.

Compared to a call with an OAuth Authorization token, we are going to call a different endpoint that will correlate the data attached to the user session.

The special endpoint, only available through a limited set of IP address answers to /v1/session/recover and requires an “Authorization” header similar to OAuth, but with a Session token instead.

curl -v -H 'Content-Type: application/json' \
        -H "Authorization: Session e73f75c00115f45416b121e274fd77b60376ce4084267ed76ce3ec7c0a9f4f1f" \
        'https://profile.accounts.webplatform.org/v1/session/recover'

NOTE This endpoint is available at the moment but might not be accessible anymore by end of June 2014.

Here are the HTTP Response body the endpoint would return:

  • 200 OK, with JSON object containing data when session exist (shown above)
  • 410 GONE, with error JSON object, when session doesn’t exist
  • 401 UNAUTHORIZED, with error JSON object, when request is malformed
4.1. Under the hood

Under the hood, the profile server endpoint at GET /v1/session/recover makes database queries similar to:

SELECT
  HEX(s.uid) AS uid,
  a.normalizedEmail AS email,
  a.username AS username,
  a.fullName AS fullName
FROM
  sessionTokens AS s,
  accounts AS a
WHERE
  s.uid = a.uid
AND
  tokenData = unhex('e73f75c00115f45416b121e274fd77b60376ce4084267ed76ce3ec7c0a9f4f1f');

The database should either return an empty result, or user data:

   +----------------------------------+----------------+----------+--------------+
   | uid                              | email          | username | fullName     |
   +----------------------------------+----------------+----------+--------------+
   | 3E09D6DF843341BC921A25423AB83BAF | hi@example.org | jdoe     | John Doe     |
   +----------------------------------+----------------+----------+--------------+

5. Returning the data to the server

If the web application could get a response from session/recover, and finds a result, it returns a JSON object:

  {"username": "jdoe",
   "fullName": "John Doe",
   "email": "hi@example.org",
   "uid": "3E09D6DF843341BC921A25423AB83BAF" }

In the case of an invalid or expired sessionToken, an error should be returned. See possible errors at #4. Read data from the profile server with a recoveryPayload

The rest MUST comply to what’s described in #Initialize local web application session.


JavaScript shared module: Detect and start automatically a session

This step is meant to address the Detect automatically if there is a session in the accounts server, and start it automatically workflow.

It describes the behavior of a non blocking JavaScript module meant to detect and start automatically a session for the user.

Client code is available in JavaScript SsoHandler class in this gist

1. Event handler to validate a session

NOTE: This event handler is about answering what’s triggered bye the module and is installed in the accounts server code.

In our own fork and branch of fxa-content-server, in app/scripts/views/base.js

      // WebPlatform Specific ===============================
      // file: app/scripts/views/base.js
      // line: 40
      // See: /docs/WPD/Projects/SSO/How_we_implemented_it#JavaScript_shared_module:_Detect_and_start_automatically_a_session
      var fxaC = this.fxaClient;
      function readAndReplyHasSession( e ) {
        var b = window.localStorage.getItem('__fxa_session');
        var sessionData = JSON.parse(b);

        fxaC.isSignedIn(sessionData.sessionToken)
            .done(
              function( promised ){
                // Will eventually change, as decribed in
                //   /docs/WPD/Projects/SSO/Improvements_roadmap#Recovering_session_data
                e.source.postMessage({hasSession: promised, recoveryPayload: sessionData.sessionToken || null}, e.origin);
              }
            );
      }
      window.addEventListener("message", readAndReplyHasSession, false);
      // /WebPlatform Specific ==============================

2. And adjust the CSP policies

NOTE: The Content Security Policy changes here is about letting the module to communicate with the accounts server.

In our own fork and branch of fxa-content-server, in server/bin/fxa-content-server.js

  // WebPlatform Specific ===============================
  // File: server/bin/fxa-content-server.js
  // Line: 62
  // Adjust helmet to accept xss from specific hosts
  // see: https://github.com/evilpacket/helmet
  // Comment, this:
  //app.use(helmet.xframe('deny'));
  // Instead:
  app.use(helmet.csp({"script-src":["'self'", "*.webplatform.org", "*.mroftalpbew.org", "*.global.ssl.fastly.net", "*.w3.org"]}));
  // /WebPlatform Specific ==============================

3. JavaScript client to handle automatic sign in

This section describes what the module actually does.

  • Make sure it doesn’t try to do things if local web app already has a session
  • Provide a way to tell whether it already has a session opened
  • Make sure it works without any external library; to be deployed on all SSO relying parties
  • Handle creation of iframe and the postMessage()
  • Handle reply from cross-frame request
  • Cross-frame response, validate returned values has keys: [hasSession, sessionToken]
  • Make async call to local web application callback through POST
  • If return response of local callback is successful, reload page

It exposes two methods to the window.sso object:

  • window.sso.init(hasSessionClosure, localCallbackEndpoint); To initialize (see below)
  • window.sso.doCheck(), to bootstrap the process (should be done once the iframe is loaded).
    window.sso.init(
      function () {
        /*
           Provide a closure that
           will tell the JavaScript
           handler whether the current
           visitor has a session or
           not.

           MUST RETURN a bool

           false === no session, please start the checks
         */
        return false;
      },
      '/wiki/Special:AccountsHandler/callback'
    );

Once the iframe is loaded:

window.sso.doCheck();

NOTE: If we had a session, the communication triggered at window.sso.doCheck();, would had provided us the required data to start automatically and resume at #Initialize local web application session.