diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,3 +1,12 @@
+0.9.7
+=====
+
+* Added support for [LinkedIn OAuth2 API](https://developer.linkedin.com/docs/oauth2).
+  Added new options `linkedin_client_id` and `linkedin_client_secret`. They are optional
+  as well as Google's `client_id` and `client_secret`. The user is now redirected to the
+  `sproxy/login` page to choose an OAuth2 provider.
+
+
 0.9.6
 =====
 
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# sproxy - secure proxy - HTTP proxy for authenticating users via Google OAuth2
+# Sproxy - HTTP proxy for authenticating users via OAuth2
 
 ## Motivation
 
@@ -9,36 +9,34 @@
  * sproxy is independent.  Any web application written in any language can use
    it.
 
+
 ## How it Works
 
-When an HTTP client makes a request, sproxy checks for a *session cookie*.  If it
-doesn't exist (or it's invalid), it redirects the client to a Google
-authentication page.  The user is then prompted to allow the application to
-access information on the user (email address).  If the user proceeds, they're
-redirected back to sproxy with a code from Google.  We then take that code and
-send it to Google ourselves to get back an access token.  Then, we use the
-access token to make another call to Google, this time to their user info API
-to retrieve the user's email address.  Finally, we store the the email address
-in a session cookie: signed with a hash to prevent tampering, set for HTTP only (to
-prevent malicious JavaScript from reading it), and set it for secure (since we
-don't want it traveling over plaintext HTTP connections).
+When an HTTP client makes a request, Sproxy checks for a *session
+cookie*.  If it doesn't exist (or it's invalid, expired), it redirects
+the client to the login page, where the user can choose
+[OAuth2](https://tools.ietf.org/html/rfc6749) provider to authenticate with.
+Finally, we store the the email address in a session cookie: signed with a
+hash to prevent tampering, set for HTTP only (to prevent malicious JavaScript
+from reading it), and set it for secure (since we don't want it traveling
+over plaintext HTTP connections).
 
 From that point on, when sproxy detects a valid session cookie it extracts the
 email, checks it against the access rules, and relays the request to the
 back-end server (if allowed).
 
+
 ## Logout
 
-Hitting the endpoint `/sproxy/logout` will invalidate the session
-cookie.  The user will be redirected to `/` after logout.  The
-query parameter `state` can be provided to specify an alternate redirect path
-(the path has to be percent-encoded, e.g. with `urlEncode` from
-`Network.HTTP.Types.URI`).
+Hitting the endpoint `/sproxy/logout` will invalidate the session cookie.
+The user will be redirected to `/` after logout.  The query parameter `state`
+can be provided to specify an alternate URL-encoded redirect path
 
+
 ## Robots
 
-Since all sproxied resources are private, it doesn't make sense for web crawlers
-to try to index them. In fact, crawlers will index only the Google authentication
+Since all sproxied resources are private, it doesn't make sense for web
+crawlers to try to index them. In fact, crawlers will index only the login
 page. To prevent this, sproxy returns the following for `/robots.txt`:
 
 ```
@@ -46,6 +44,7 @@
 Disallow: /
 ```
 
+
 ## Permissions system
 
 Permissions are stored in a PostgreSQL database. See sproxy.sql for details.
@@ -63,28 +62,29 @@
   whether a user is allowed to perform a request. This is often a bit
   surprising, please see the following example:
 
+
 ### Privileges example
 
 Consider this `group_privilege` and `privilege_rule` relations:
 
 group            | privilege | domain
 ---------------- | --------- | -----------------
-`readers`        | `basic`   | `wiki.zalora.com`
-`readers`        | `read`    | `wiki.zalora.com`
-`editors`        | `basic`   | `wiki.zalora.com`
-`editors`        | `read`    | `wiki.zalora.com`
-`editors`        | `edit`    | `wiki.zalora.com`
-`administrators` | `basic`   | `wiki.zalora.com`
-`administrators` | `read`    | `wiki.zalora.com`
-`administrators` | `edit`    | `wiki.zalora.com`
-`administrators` | `admin`   | `wiki.zalora.com`
+`readers`        | `basic`   | `wiki.example.com`
+`readers`        | `read`    | `wiki.example.com`
+`editors`        | `basic`   | `wiki.example.com`
+`editors`        | `read`    | `wiki.example.com`
+`editors`        | `edit`    | `wiki.example.com`
+`administrators` | `basic`   | `wiki.example.com`
+`administrators` | `read`    | `wiki.example.com`
+`administrators` | `edit`    | `wiki.example.com`
+`administrators` | `admin`   | `wiki.example.com`
 
 privilege   | domain            | path           | method
 ----------- | ----------------- | -------------- | ------
-`basic`     | `wiki.zalora.com` | `/%`           | `GET`
-`read`      | `wiki.zalora.com` | `/wiki/%`      | `GET`
-`edit`      | `wiki.zalora.com` | `/wiki/edit/%` | `%`
-`admin`     | `wiki.zalora.com` | `/admin/%`     | `%`
+`basic`     | `wiki.example.com` | `/%`           | `GET`
+`read`      | `wiki.example.com` | `/wiki/%`      | `GET`
+`edit`      | `wiki.example.com` | `/wiki/edit/%` | `%`
+`admin`     | `wiki.example.com` | `/admin/%`     | `%`
 
 With this setup, everybody (that is `readers`, `editors` and `administrators`s)
 will have access to e.g. `/imgs/logo.png` and `/favicon.ico`, but only
@@ -118,37 +118,15 @@
 devops           | all, devops    | devops
 devops           | all            | Access denied
 
+
 ## Configuration File
 
-By default `sproxy` will read its configuration from `config/sproxy.yml`.  You
-can specify a custom path with:
+By default `sproxy` will read its configuration from
+`config/sproxy.yml`.  There is example file with documentation
+[config/sproxy.yml.example](config/sproxy.yml.example). You can specify a
+custom path with:
 
 ```
 sproxy --config /path/to/sproxy.yml
 ```
-
-## Development
-
-```
-$ cp config/sproxy.yml.example config/sproxy.yml
-```
-
-Make sure that you have the following entry in `/etc/hosts`:
-
-```
-127.0.0.1       dev.zalora.com
-```
-
-
-### Create OAuth credentials
-
-Create a project in the [Google Developers Console](https://console.developers.google.com/project).
-
- - visit *APIs & auth* -> *Credentials*
- - select *CREATE NEW CLIENT ID*
- - use `https://dev.zalora.com` as *Authorized JavaScript origins*
- - use `https://dev.zalora.com/sproxy/oauth2callback` as *Authorized redirect URI*
-
-Put the `Client ID` in `config/sproxy.yml` and the `Client secret` in a file
-called `config/client_secret`.
 
diff --git a/config/sproxy.yml.example b/config/sproxy.yml.example
--- a/config/sproxy.yml.example
+++ b/config/sproxy.yml.example
@@ -4,7 +4,7 @@
 
 # The port sproxy listens on (this is always HTTPS).
 # Default is 443
-# listen: 443
+listen: 4430
 
 # User to run as.
 # If launched with root privileges, sproxy will switch
@@ -17,12 +17,18 @@
 # to the above listen port. Default is "yes" if listen is 443
 # redirect_http_to_https: yes
 
-cookie_domain: dev.zalora.com
+cookie_domain: example.com
 cookie_name: sproxy-dev
 
-# The client ID and client secret come from Google's "Cloud Console".
-client_id: a611zak494jxdgdn6ltlkn547rme91ig.apps.googleusercontent.com
-client_secret: config/client_secret
+
+# Google OAuth2 client ID & secret.
+# The names are such for hysterical raisins:
+# client_id: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
+# client_secret: secret/file
+
+# LinkedIn OAuth2 client ID & secret.
+# linkedin_client_id: xxxxxxxxxxxxxx
+# linkedin_client_secret: secret/file
 
 ssl_key: config/server.key.example
 
diff --git a/sproxy.cabal b/sproxy.cabal
--- a/sproxy.cabal
+++ b/sproxy.cabal
@@ -1,6 +1,6 @@
 name: sproxy
-version: 0.9.6
-synopsis: HTTP proxy for authenticating users via Google OAuth2
+version: 0.9.7
+synopsis: HTTP proxy for authenticating users via OAuth2
 license: MIT
 license-file: LICENSE
 copyright: 2013-2016, Zalora South East Asia Pte. Ltd
@@ -20,15 +20,18 @@
 executable sproxy
   main-is: Main.hs
   other-modules:
-      Authenticate
-    , Authorize
-    , ConfigFile
-    , Cookies
-    , HTTP
-    , Logging
-    , Proxy
-    , Type
-    , Util
+    Authenticate
+    Authenticate.Google
+    Authenticate.LinkedIn
+    Authenticate.Token
+    Authorize
+    ConfigFile
+    Cookies
+    HTTP
+    Logging
+    Proxy
+    Type
+    Util
 
   hs-source-dirs: src
   default-language: Haskell2010
diff --git a/sproxy.sql b/sproxy.sql
--- a/sproxy.sql
+++ b/sproxy.sql
@@ -12,7 +12,6 @@
 -- | devops       |
 -- | all          |
 -- | regional     |
--- | SG HQ        |
 
 CREATE EXTENSION IF NOT EXISTS citext;
 
@@ -24,20 +23,11 @@
 
 -- | group        | email                  |
 -- |--------------+------------------------|
--- | data science | blah@zalora.com        |
--- | data science | foo@zalora.com         |
--- | devops       | devops1@zalora.com     |
--- | devops       | devops2@zalora.com     |
--- | all          | %@zalora.com           |
--- | all          | %@zalora.sg            |
--- | all          | %@zalora.vn            |
--- | all          | %@zalora.com.hk        |
--- | all          | %@zalora.com.my        |
--- | all          | %@zalora.com.ph        |
--- | all          | %@zalora.co.id         |
--- | all          | %@zalora.co.th         |
--- | regional     | %@zalora.com           |
--- | SG           | %@zalora.sg            |
+-- | data science | blah@example.com        |
+-- | data science | foo@example.com         |
+-- | devops       | devops1@example.com     |
+-- | devops       | devops2@example.com     |
+-- | all          | %@example.com           |
 
 -- Find out which groups a user (email address) belongs to:
 -- SELECT "group" FROM group_member WHERE 'email.address' LIKE email
@@ -48,9 +38,9 @@
 
 -- | domain                |
 -- |-----------------------|
--- | app1.zalora.com       |
--- | app2.zalora.com       |
--- | app3.zalora.com       |
+-- | app1.example.com       |
+-- | app2.example.com       |
+-- | app3.example.com       |
 
 CREATE TABLE IF NOT EXISTS privilege (
   "domain" TEXT REFERENCES domain (domain) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
@@ -60,10 +50,10 @@
 
 -- | domain                | privilege  |
 -- |-----------------------+------------|
--- | app3.zalora.com       | view       |
--- | app3.zalora.com       | export     |
--- | app1.zalora.com       | list users |
--- | app1.zalora.com       | add users  |
+-- | app3.example.com       | view       |
+-- | app3.example.com       | export     |
+-- | app1.example.com       | list users |
+-- | app1.example.com       | add users  |
 
 CREATE TABLE IF NOT EXISTS privilege_rule (
   "domain" TEXT NOT NULL,
@@ -76,11 +66,11 @@
 
 -- | domain                | privilege  | path      | method |
 -- |-----------------------+------------+-----------+--------|
--- | app3.zalora.com       | view       | /%        | %      |
--- | app3.zalora.com       | export     | /export/% | %      |
--- | app1.zalora.com       | list users | /users    | GET    |
--- | app1.zalora.com       | list users | /user/%   | GET    |
--- | app1.zalora.com       | add users  | /users    | POST   |
+-- | app3.example.com       | view       | /%        | %      |
+-- | app3.example.com       | export     | /export/% | %      |
+-- | app1.example.com       | list users | /users    | GET    |
+-- | app1.example.com       | list users | /user/%   | GET    |
+-- | app1.example.com       | add users  | /users    | POST   |
 
 CREATE TABLE IF NOT EXISTS group_privilege (
   "group" TEXT REFERENCES "group" ("group") ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
@@ -92,10 +82,10 @@
 
 -- | group        | domain                | privilege  |
 -- |--------------+-----------------------+------------|
--- | data science | app3.zalora.com       | view       |
--- | data science | app3.zalora.com       | export     |
--- | all          | app1.zalora.com       | list users |
--- | devops       | app1.zalora.com       | add users  |
+-- | data science | app3.example.com       | view       |
+-- | data science | app3.example.com       | export     |
+-- | all          | app1.example.com       | list users |
+-- | devops       | app1.example.com       | add users  |
 
 -- Check if the user is authorized for the request. Let's break it
 -- down for understanding:
@@ -106,19 +96,19 @@
 --
 -- SELECT p.privilege FROM privilege p
 -- INNER JOIN privilege_rule pr ON pr."domain" = p."domain" AND pr.privilege = p.privilege
--- WHERE 'app3.zalora.com' LIKE pr."domain" AND '/export/test' LIKE "path" AND 'GET' ILIKE "method"
+-- WHERE 'app3.example.com' LIKE pr."domain" AND '/export/test' LIKE "path" AND 'GET' ILIKE "method"
 -- ORDER by array_length(regexp_split_to_array("path", '/'), 1) DESC LIMIT 1
 --
 -- To get the groups that grant the user access, put that in a subquery:
 --
 -- SELECT gp."group" FROM group_privilege gp
 -- INNER JOIN group_member gm ON gm."group" = gp."group"
--- WHERE 'blah@zalora.com' LIKE email
--- AND 'app3.zalora.com' LIKE "domain"
+-- WHERE 'blah@example.com' LIKE email
+-- AND 'app3.example.com' LIKE "domain"
 -- AND privilege IN (
 --   SELECT p.privilege FROM privilege p
 --   INNER JOIN privilege_rule pr ON pr."domain" = p."domain" AND pr.privilege = p.privilege
---   WHERE 'app3.zalora.com' LIKE pr."domain" AND '/export/test' LIKE "path" AND 'GET' ILIKE "method"
+--   WHERE 'app3.example.com' LIKE pr."domain" AND '/export/test' LIKE "path" AND 'GET' ILIKE "method"
 --   ORDER by array_length(regexp_split_to_array("path", '/'), 1) DESC LIMIT 1
 -- )
 --
@@ -128,7 +118,7 @@
 -- SELECT COUNT(*) > 0 FROM group_privilege gp
 --
 -- Note for the future: If you want to support wildcards that match
--- only a single path component (e.g. app1.zalora.com/user/:/email),
+-- only a single path component (e.g. app1.example.com/user/:/email),
 -- you could try something like:
 --
 -- WHERE 'url' ~ regexp_replace(url, ':', '[^/]+')
@@ -138,11 +128,11 @@
 
 -- Example data for development:
 /*
-  INSERT INTO domain (domain) VALUES ('dev.zalora.com');
+  INSERT INTO domain (domain) VALUES ('example.com');
   INSERT INTO "group" ("group") VALUES ('dev');
   INSERT INTO group_member ("group", email) VALUES ('dev', '%');
-  INSERT INTO privilege (domain, privilege) VALUES ('dev.zalora.com', 'full');
-  INSERT INTO group_privilege ("group", domain, privilege) VALUES ('dev', 'dev.zalora.com', 'full');
-  INSERT INTO privilege_rule (domain, privilege, path, method) VALUES ('dev.zalora.com', 'full', '%', '%');
+  INSERT INTO privilege (domain, privilege) VALUES ('example.com', 'full');
+  INSERT INTO group_privilege ("group", domain, privilege) VALUES ('dev', 'example.com', 'full');
+  INSERT INTO privilege_rule (domain, privilege, path, method) VALUES ('example.com', 'full', '%', '%');
 */
 
diff --git a/src/Authenticate.hs b/src/Authenticate.hs
--- a/src/Authenticate.hs
+++ b/src/Authenticate.hs
@@ -1,139 +1,61 @@
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
 
 module Authenticate (
-  AuthConfig(..)
-, AuthToken(..)
+  logout
+, loginPage
+, redirectToLoginPage
 , validAuth
-, redirectForAuth
-, authenticate
-, logout
-
--- exported to silence warnings
-, AccessToken(..)
 ) where
 
-import           Control.Applicative
-import           Control.Exception
-import           Text.Read (readMaybe)
-import           Data.Monoid
-import           Data.ByteString (ByteString)
-import qualified Data.ByteString.Char8 as B8
+import Data.ByteString (ByteString)
+import Data.Monoid ((<>))
+import Network.HTTP.Toolkit (Response, BodyReader)
+import Network.HTTP.Types (found302, ok200, urlEncode)
+import System.Posix.Time (epochTime)
+import Text.InterpolatedString.Perl6 (qc)
+import Text.Read (readMaybe)
 import qualified Data.ByteString.UTF8 as UTF8
-import qualified Data.ByteString.Lazy.Char8 as BL8
-import           Data.String.Conversions (cs)
-import           Data.List.Split (splitOn)
-import           Data.Aeson
-import           Network.HTTP.Types
-import           System.Posix.Types (EpochTime)
-import           System.Posix.Time (epochTime)
-import           Data.Digest.Pure.SHA (hmacSha1, showDigest)
-import           Network.HTTP.Conduit (simpleHttp, parseUrl, httpLbs, RequestBody(..))
-import qualified Network.HTTP.Conduit as HTTP
-import           Network.HTTP.Toolkit
 
-
-import           Cookies
-import           HTTP
-import qualified Logging as Log
-
-data AuthConfig = AuthConfig {
-  authConfigCookieDomain :: String
-, authConfigCookieName :: String
-, authConfigClientID :: String
-, authConfigClientSecret :: String
-, authConfigAuthTokenKey :: String
-, authConfigShelfLife :: EpochTime
-} deriving (Eq, Show)
-
-data AccessToken = AccessToken {
-  accessToken :: String
-, expiresIn :: Integer
-, tokenType :: String
-} deriving (Eq, Show)
-
-instance FromJSON AccessToken where
-  parseJSON (Object v) = AccessToken
-    <$> v .: "access_token"
-    <*> v .: "expires_in"
-    <*> v .: "token_type"
-  parseJSON _ = empty
-
-data AuthToken = AuthToken {
-  authEmail  :: String
-, authName   :: (String, String)
-, authExpiry :: EpochTime
-, authDigest :: String -- HMAC hash
-}
-
--- Here is the format of the actual cookie we send to the client.
-instance Show AuthToken where
-  show a = authEmail a ++ ":" ++ authNameString (authName a) ++ ":" ++ show (authExpiry a) ++ ":" ++ authDigest a
-    where authNameString (given, family) = given ++ ":" ++ family
-
-instance Read AuthToken where
-  readsPrec _ s = case splitOn ":" s of
-    [email, given, family, expire, digest] -> [(AuthToken email (given, family) (read expire) digest, "")]
-    _ -> []
-
-data UserInfo = UserInfo {
-  userEmail :: String
-, userGivenName :: String
-, userFamilyName :: String
-} deriving (Eq, Show)
-
-instance FromJSON UserInfo where
-  parseJSON (Object v) = UserInfo
-    <$> v .: "email"
-    <*> v .: "given_name"
-    <*> v .: "family_name"
-  parseJSON _ = empty
-
-redirectUri :: ByteString -> ByteString
-redirectUri baseUri = baseUri <> "/sproxy/oauth2callback"
-
-authUrl :: ByteString -> ByteString -> AuthConfig -> ByteString
-authUrl path baseUri c = mconcat [
-    "https://accounts.google.com/o/oauth2/auth?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&"
-  , "state=", urlEncode True path
-  , "&redirect_uri=", redirectUri baseUri
-  , "&response_type=code&client_id=", B8.pack (authConfigClientID c)
-  , "&approval_prompt=force&access_type=offline"
-  ]
+import Authenticate.Token (AuthToken(..), tokenDigest)
+import Authenticate.Types (AuthConfig(..))
+import Cookies (invalidateCookie)
+import HTTP (mkResponse, mkHtmlResponse)
+import qualified Authenticate.Google as Google
+import qualified Authenticate.LinkedIn as LinkedIn
 
-redirectForAuth :: AuthConfig -> ByteString -> ByteString -> IO (Response BodyReader)
-redirectForAuth c path baseUri = mkResponse found302 [("Location", authUrl path baseUri c)] ""
+redirectToLoginPage :: ByteString -> ByteString -> IO (Response BodyReader)
+redirectToLoginPage base path =
+  mkResponse found302 [
+    ("Location", base <> "/sproxy/login?state=" <> urlEncode True path)
+  ] ""
 
-authenticate :: AuthConfig -> ByteString -> ByteString -> ByteString -> IO (Response BodyReader)
-authenticate config baseUri path code = do
-  Log.info ("authentication request with code " ++ show code)
-  tokenRes <- try $ post "https://accounts.google.com/o/oauth2/token"
-              (cs $ "code=" ++ cs code ++ "&client_id=" ++ clientID
-                ++ "&client_secret=" ++ clientSecret ++ "&redirect_uri="
-                ++ cs (redirectUri baseUri) ++ "&grant_type=authorization_code")
-  case tokenRes of
-    Left err -> authenticationFailed ("error while authenticating: " ++ show (err :: HTTP.HttpException))
-    Right resp ->
-      case decode (HTTP.responseBody resp) of
-        Nothing ->
-          authenticationFailed "Received an invalid response from Google's authentication server."
-        Just token -> do
-          infoRes <- try $ simpleHttp ("https://www.googleapis.com/oauth2/v1/userinfo?access_token=" ++ accessToken token)
-          case infoRes of
-            Left err -> authenticationFailed ("error while retrieving user info: " ++ show (err :: HTTP.HttpException))
-            Right body ->
-              case decode body of
-                Nothing -> authenticationFailed "Received an invalid user info response from Google's authentication server."
-                Just userInfo -> do
-                  clientToken <- authToken config userInfo
-                  let cookie = setCookie cookieDomain cookieName (show clientToken) (authConfigShelfLife config)
-                  mkResponse found302 [("Location", baseUri <> urlDecode False path), ("Set-Cookie", UTF8.fromString cookie)] ""
+loginPage :: AuthConfig -> ByteString -> ByteString -> IO (Response BodyReader)
+loginPage c base path =
+  mkHtmlResponse ok200 body
   where
-    cookieDomain = authConfigCookieDomain config
-    cookieName = authConfigCookieName config
-    clientID = authConfigClientID config
-    clientSecret = authConfigClientSecret config
+    google :: ByteString
+    google = maybe "" (\u -> [qc|<p><a href="{u}">Authenticate with Google</a></p>|]) (Google.authUrl c base path)
+    linkedin :: ByteString
+    linkedin = maybe "" (\u -> [qc|<p><a href="{u}">Authenticate with LinkedIn</a></p>|]) (LinkedIn.authUrl c base path)
+    body = [qc|
+      <!DOCTYPE html>
+      <html lang="en">
+        <head>
+          <meta charset="utf-8">
+          <title>Authentication required</title>
+        </head>
+        <body style="text-align:center;">
+        <h1>Authentication required</h1>
+          {google}
+          {linkedin}
+        </body>
+      </html>
+      |]
 
+-- ("Location", Google.authUrl path baseUri clientId)
+
 logout :: AuthConfig -> ByteString -> IO (Response BodyReader)
 logout config url = do
   let cookie = invalidateCookie cookieDomain cookieName
@@ -142,18 +64,6 @@
     cookieDomain = authConfigCookieDomain config
     cookieName = authConfigCookieName config
 
-post :: String -> ByteString -> IO (HTTP.Response BL8.ByteString)
-post url body = do
-  r' <- parseUrl url
-  let r = r' { HTTP.method = "POST"
-             , HTTP.requestBody = RequestBodyBS body
-             , HTTP.requestHeaders
-               = [ ("Content-Type" , "application/x-www-form-urlencoded")
-                 ]
-             }
-  manager <- HTTP.newManager HTTP.tlsManagerSettings
-  httpLbs r manager
-
 validAuth :: AuthConfig -> String -> IO (Maybe AuthToken)
 validAuth config token =
   case readMaybe token of
@@ -165,35 +75,4 @@
         else return Nothing
   where
     key = authConfigAuthTokenKey config
-
--- | Create an AuthToken with the default expiration time, automatically
--- calculating the digest.
-authToken :: AuthConfig -> UserInfo -> IO AuthToken
-authToken acfg user = do
-  now <- epochTime
-  let
-      email = userEmail user
-      name = (userGivenName user, userFamilyName user)
-      expires = now + authConfigShelfLife acfg
-      digest = tokenDigest (authConfigAuthTokenKey acfg) AuthToken {
-          authEmail = email
-        , authName = name
-        , authExpiry = expires
-        , authDigest = ""
-        }
-      token = AuthToken {
-          authEmail  = email
-        , authName = name
-        , authExpiry = expires
-        , authDigest = digest
-        }
-  return token
-
--- | This generates the HMAC digest of the auth token using SHA1.
--- Eventually, we need to rotate the key used to generate the HMAC, while still
--- storing old keys long enough to use them for any valid login session. Without
--- this, authentication is less secure.
-tokenDigest :: String -> AuthToken -> String
-tokenDigest key a = showDigest $ hmacSha1 (BL8.pack key) (BL8.pack token)
-  where token = show (authEmail a) ++ show (authExpiry a)
 
diff --git a/src/Authenticate/Google.hs b/src/Authenticate/Google.hs
new file mode 100644
--- /dev/null
+++ b/src/Authenticate/Google.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Authenticate.Google (
+  authUrl
+, authenticate
+) where
+
+import Control.Applicative (empty)
+import Control.Exception (try)
+import Data.Aeson (FromJSON, decode, parseJSON, Value(Object), (.:))
+import Data.ByteString (ByteString)
+import Data.Maybe (fromJust)
+import Data.Monoid ((<>))
+import Network.HTTP.Toolkit (Response, BodyReader)
+import Network.HTTP.Types (found302, urlEncode, urlDecode)
+import qualified Data.ByteString.UTF8 as UTF8
+import qualified Network.HTTP.Conduit as HTTP
+
+import Authenticate.Types (AccessToken(..), AuthConfig(..), OAuthClient(..))
+import Authenticate.Token (AuthUser(..), mkAuthToken)
+import Cookies (setCookie)
+import HTTP (authenticationFailed, mkResponse, post, get)
+import qualified Logging as Log
+
+authUrl :: AuthConfig -> ByteString -> ByteString -> Maybe ByteString
+authUrl c base path = url . oauthClientId <$> authConfigGoogleClient c
+  where
+    url cid = mconcat [
+        "https://accounts.google.com/o/oauth2/auth"
+      , "?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile"
+      , "&state=", urlEncode True path
+      , "&redirect_uri=", redirectUri base
+      , "&response_type=code&client_id=", cid
+      , "&approval_prompt=force&access_type=offline"
+      ]
+
+authenticate :: AuthConfig -> ByteString -> ByteString -> ByteString -> IO (Response BodyReader)
+authenticate acfg base path code = do
+  Log.info ("Google authentication request with code " ++ show code)
+  tokenRes <- try $ post "https://accounts.google.com/o/oauth2/token"
+              ("code=" <> code <> "&client_id=" <> clientId
+                <> "&client_secret=" <> clientSecret <> "&redirect_uri="
+                <> (redirectUri base) <> "&grant_type=authorization_code")
+  case tokenRes of
+    Left err -> authenticationFailed ("error while authenticating: " ++ show (err :: HTTP.HttpException))
+    Right tresp -> do
+      Log.debug (show tresp)
+      case decode (HTTP.responseBody tresp) of
+        Nothing ->
+          authenticationFailed "Received an invalid response from Google's authentication server."
+        Just gtoken -> do
+          infoRes <- try $ get ("https://www.googleapis.com/oauth2/v1/userinfo?access_token=" ++ accessToken gtoken) []
+          case infoRes of
+            Left err -> authenticationFailed ("error while retrieving user info: " ++ show (err :: HTTP.HttpException))
+            Right uresp ->
+              case decode (HTTP.responseBody uresp) of
+                Nothing -> authenticationFailed "Received an invalid user info response from Google's authentication server."
+                Just (GoogleUserInfo{email, givenName, familyName}) -> do
+                  token <- mkAuthToken acfg AuthUser{ authUserEmail = email
+                                                    , authUserGivenName = givenName
+                                                    , authUserFamilyName = familyName }
+                  let cookie = setCookie cookieDomain cookieName (show token) (authConfigShelfLife acfg)
+                  mkResponse found302 [("Location", base <> urlDecode False path), ("Set-Cookie", UTF8.fromString cookie)] ""
+  where
+    cookieDomain = authConfigCookieDomain acfg
+    cookieName = authConfigCookieName acfg
+    clientId = oauthClientId . fromJust $ authConfigGoogleClient acfg
+    clientSecret = oauthClientSecret . fromJust $ authConfigGoogleClient acfg
+
+
+redirectUri :: ByteString -> ByteString
+redirectUri base = base <> "/sproxy/oauth2callback"
+
+data GoogleUserInfo = GoogleUserInfo {
+  email :: String
+, givenName :: String
+, familyName :: String
+} deriving (Eq, Show)
+
+instance FromJSON GoogleUserInfo where
+  parseJSON (Object v) = GoogleUserInfo
+    <$> v .: "email"
+    <*> v .: "given_name"
+    <*> v .: "family_name"
+  parseJSON _ = empty
+
+
diff --git a/src/Authenticate/LinkedIn.hs b/src/Authenticate/LinkedIn.hs
new file mode 100644
--- /dev/null
+++ b/src/Authenticate/LinkedIn.hs
@@ -0,0 +1,89 @@
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Authenticate.LinkedIn (
+  authUrl
+, authenticate
+) where
+
+import Control.Applicative (empty)
+import Control.Exception (try)
+import Data.Aeson (FromJSON, decode, parseJSON, Value(Object), (.:))
+import Data.ByteString (ByteString)
+import Data.Maybe (fromJust)
+import Data.Monoid ((<>))
+import Network.HTTP.Toolkit (Response, BodyReader)
+import Network.HTTP.Types (found302, urlEncode, urlDecode)
+import qualified Data.ByteString.Char8 as B8
+import qualified Data.ByteString.UTF8 as UTF8
+import qualified Network.HTTP.Conduit as HTTP
+
+import Authenticate.Types (AccessToken(..), AuthConfig(..), OAuthClient(..))
+import Authenticate.Token (AuthUser(..), mkAuthToken)
+import Cookies (setCookie)
+import HTTP (authenticationFailed, mkResponse, post, get)
+import qualified Logging as Log
+
+authUrl :: AuthConfig -> ByteString -> ByteString -> Maybe ByteString
+authUrl c base path = url . oauthClientId <$> authConfigLinkedInClient c
+  where
+    url cid = mconcat [
+        "https://www.linkedin.com/oauth/v2/authorization"
+      , "?scope=r_basicprofile%20r_emailaddress"
+      , "&state=", urlEncode True path
+      , "&redirect_uri=", redirectUri base
+      , "&response_type=code&client_id=", cid
+      ]
+
+authenticate :: AuthConfig -> ByteString -> ByteString -> ByteString -> IO (Response BodyReader)
+authenticate acfg base path code = do
+  Log.info ("LinkedIn authentication request with code " ++ show code)
+  tokenRes <- try $ post "https://www.linkedin.com/oauth/v2/accessToken"
+              ("code=" <> code <> "&client_id=" <> clientId
+                <> "&client_secret=" <> clientSecret <> "&redirect_uri="
+                <> (redirectUri base) <> "&grant_type=authorization_code")
+  case tokenRes of
+    Left err -> authenticationFailed ("error while authenticating: " ++ show (err :: HTTP.HttpException))
+    Right tresp -> do
+      Log.debug (show tresp)
+      case decode (HTTP.responseBody tresp) of
+        Nothing ->
+          authenticationFailed "Received an invalid response from LinkedIn authentication server."
+        Just oat -> do
+          infoRes <- try $ get ("https://api.linkedin.com/v1/people/~:(email-address,first-name,last-name)?format=json")
+                               [ ("Authorization", "Bearer " <> B8.pack (accessToken oat)) ]
+          case infoRes of
+            Left err -> authenticationFailed ("error while retrieving user info: " ++ show (err :: HTTP.HttpException))
+            Right uresp -> do
+              Log.debug (show uresp)
+              case decode (HTTP.responseBody uresp) of
+                Nothing -> authenticationFailed "Received an invalid user info response from LinkedIn authentication server."
+                Just (LinkedInUserInfo{emailAddress, firstName, lastName}) -> do
+                  token <- mkAuthToken acfg AuthUser{ authUserEmail = emailAddress
+                                                    , authUserGivenName = firstName
+                                                    , authUserFamilyName = lastName }
+                  let cookie = setCookie cookieDomain cookieName (show token) (authConfigShelfLife acfg)
+                  mkResponse found302 [("Location", base <> urlDecode False path), ("Set-Cookie", UTF8.fromString cookie)] ""
+  where
+    cookieDomain = authConfigCookieDomain acfg
+    cookieName = authConfigCookieName acfg
+    clientId = oauthClientId . fromJust $ authConfigLinkedInClient acfg
+    clientSecret = oauthClientSecret . fromJust $ authConfigLinkedInClient acfg
+
+
+redirectUri :: ByteString -> ByteString
+redirectUri base = base <> "/sproxy/oauth2callback/linkedin"
+
+data LinkedInUserInfo = LinkedInUserInfo {
+  emailAddress :: String
+, firstName :: String
+, lastName :: String
+} deriving (Eq, Show)
+
+instance FromJSON LinkedInUserInfo where
+  parseJSON (Object v) = LinkedInUserInfo
+    <$> v .: "emailAddress"
+    <*> v .: "firstName"
+    <*> v .: "lastName"
+  parseJSON _ = empty
+
diff --git a/src/Authenticate/Token.hs b/src/Authenticate/Token.hs
new file mode 100644
--- /dev/null
+++ b/src/Authenticate/Token.hs
@@ -0,0 +1,69 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Authenticate.Token (
+  AuthToken(..)
+, AuthUser(..)
+, mkAuthToken
+, tokenDigest
+) where
+
+import Data.Digest.Pure.SHA (hmacSha1, showDigest)
+import Data.List.Split (splitOn)
+import System.Posix.Time (epochTime)
+import System.Posix.Types (EpochTime)
+import qualified Data.ByteString.Lazy.Char8 as BL8
+
+import Authenticate.Types (AuthConfig(authConfigAuthTokenKey, authConfigShelfLife))
+
+data AuthUser = AuthUser {
+  authUserEmail :: String
+, authUserGivenName :: String
+, authUserFamilyName :: String
+} deriving (Eq)
+
+data AuthToken = AuthToken {
+  authDigest :: String
+, authExpiry :: EpochTime
+, authUser   :: AuthUser
+}
+
+-- Here is the format of the actual cookie we send to the client.
+instance Show AuthToken where
+  show a = user ++ ":" ++ show (authExpiry a) ++ ":" ++ authDigest a
+    where
+      u = authUser a
+      user = authUserEmail u ++
+          ":" ++ authUserGivenName u ++
+          ":" ++ authUserFamilyName u
+
+instance Read AuthToken where
+  readsPrec _ s = case splitOn ":" s of
+    [email, fn, ln, xpr, dgst]
+      -> [(AuthToken
+          { authUser = AuthUser email fn ln
+          , authExpiry = read xpr
+          , authDigest = dgst
+          }, "")]
+    _ -> []
+
+
+-- | This generates the HMAC digest of the auth token using SHA1.
+tokenDigest :: String -> AuthToken -> String
+tokenDigest key a = showDigest $ hmacSha1 (BL8.pack key) (BL8.pack token)
+  where token = show (authUserEmail . authUser $ a) ++ show (authExpiry a)
+
+-- | Create an AuthToken with the default expiration time, automatically
+-- calculating the digest.
+mkAuthToken :: AuthConfig -> AuthUser -> IO AuthToken
+mkAuthToken acfg auser = do
+  now <- epochTime
+  let
+      expires = now + authConfigShelfLife acfg
+      token = AuthToken {
+          authUser = auser
+        , authExpiry = expires
+        , authDigest = ""
+        }
+      digest = tokenDigest (authConfigAuthTokenKey acfg) token
+  return $ token {authDigest = digest}
+
diff --git a/src/ConfigFile.hs b/src/ConfigFile.hs
--- a/src/ConfigFile.hs
+++ b/src/ConfigFile.hs
@@ -26,8 +26,10 @@
 , cfRedirectHttpToHttps :: Maybe Bool
 , cfCookieDomain :: String
 , cfCookieName :: String
-, cfClientID :: String
-, cfClientSecretFile :: FilePath
+, cfGoogleClientID :: Maybe String
+, cfGoogleClientSecretFile :: Maybe FilePath
+, cfLinkedInClientID :: Maybe String
+, cfLinkedInClientSecretFile :: Maybe FilePath
 , cfSslKey :: FilePath
 , cfSslCerts :: FilePath
 , cfDatabase :: String
@@ -45,8 +47,10 @@
     <*> m .:? "redirect_http_to_https"
     <*> m .: "cookie_domain"
     <*> m .: "cookie_name"
-    <*> m .: "client_id"
-    <*> m .: "client_secret"
+    <*> m .:? "client_id"
+    <*> m .:? "client_secret"
+    <*> m .:? "linkedin_client_id"
+    <*> m .:? "linkedin_client_secret"
     <*> m .: "ssl_key"
     <*> m .: "ssl_certs"
     <*> m .: "database"
diff --git a/src/HTTP.hs b/src/HTTP.hs
--- a/src/HTTP.hs
+++ b/src/HTTP.hs
@@ -1,20 +1,25 @@
-{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+
 module HTTP (
   hostHeaderMissing
-, authenticationFailed
 , accessDenied
+, authenticationFailed
 , badRequest
+, get
+, mkHtmlResponse
 , mkResponse
 , mkTextResponse
-, mkHtmlResponse
+, post
 ) where
 
-import           Data.ByteString (ByteString)
-import qualified Data.ByteString.Char8 as B
-import           Network.HTTP.Types
-import           Network.HTTP.Toolkit
-import           Network.HTTP.Toolkit.Body
-import           Text.InterpolatedString.Perl6 (qc)
+import Network.HTTP.Toolkit
+import Network.HTTP.Toolkit.Body
+import Network.HTTP.Types
+import Text.InterpolatedString.Perl6 (qc)
+import qualified Data.ByteString.Char8 as B8
+import qualified Data.ByteString.Lazy.Char8 as BL8
+import qualified Network.HTTP.Conduit as HTTP
 
 import qualified Logging as Log
 
@@ -60,13 +65,35 @@
 badRequest :: IO (Response BodyReader)
 badRequest = mkTextResponse badRequest400 "400 Bad Request"
 
-mkResponse :: Status -> [Header] -> ByteString -> IO (Response BodyReader)
+mkResponse :: Status -> [Header] -> B8.ByteString -> IO (Response BodyReader)
 mkResponse status headers_ body = Response status headers <$> fromByteString body
   where
-    headers = ("Content-Length", B.pack . show . B.length $ body) : headers_
+    headers = ("Content-Length", B8.pack . show . B8.length $ body) : headers_
 
-mkTextResponse :: Status -> ByteString -> IO (Response BodyReader)
+mkTextResponse :: Status -> B8.ByteString -> IO (Response BodyReader)
 mkTextResponse status = mkResponse status [("Content-Type", "text/plain")]
 
-mkHtmlResponse :: Status -> ByteString -> IO (Response BodyReader)
+mkHtmlResponse :: Status -> B8.ByteString -> IO (Response BodyReader)
 mkHtmlResponse status = mkResponse status [("Content-Type", "text/html")]
+
+post :: String -> B8.ByteString -> IO (HTTP.Response BL8.ByteString)
+post url body = do
+  r' <- HTTP.parseUrl url
+  let r = r' { HTTP.method = "POST"
+             , HTTP.requestBody = HTTP.RequestBodyBS body
+             , HTTP.requestHeaders
+               = [ ("Content-Type" , "application/x-www-form-urlencoded")
+                 ]
+             }
+  manager <- HTTP.newManager HTTP.tlsManagerSettings
+  HTTP.httpLbs r manager
+
+get :: String -> [Header] -> IO (HTTP.Response BL8.ByteString)
+get url hdrs = do
+  r' <- HTTP.parseUrl url
+  let r = r' { HTTP.method = "GET"
+             , HTTP.requestHeaders = hdrs
+             }
+  manager <- HTTP.newManager HTTP.tlsManagerSettings
+  HTTP.httpLbs r manager
+
diff --git a/src/Main.hs b/src/Main.hs
--- a/src/Main.hs
+++ b/src/Main.hs
@@ -17,7 +17,7 @@
 
 usage :: String
 usage =  "SProxy " ++ showVersion version ++
-  " HTTP proxy for authenticating users via Google OAuth2" ++ [r|
+  " HTTP proxy for authenticating users via OAuth2" ++ [r|
 
 Usage:
   sproxy [options]
diff --git a/src/Proxy.hs b/src/Proxy.hs
--- a/src/Proxy.hs
+++ b/src/Proxy.hs
@@ -1,4 +1,6 @@
-{-# LANGUAGE OverloadedStrings, ScopedTypeVariables #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
 module Proxy (
   run
 ) where
@@ -37,13 +39,17 @@
 import qualified Network.TLS as TLS
 import qualified Network.TLS.Extra as TLS
 
-import Authenticate
+import Authenticate (loginPage, logout, redirectToLoginPage, validAuth)
+import Authenticate.Token (AuthToken(..), AuthUser(..))
+import Authenticate.Types (AuthConfig(..), OAuthClient(..))
 import Authorize
 import ConfigFile
 import Cookies
 import HTTP
 import Type
 import Util
+import qualified Authenticate.Google as Google
+import qualified Authenticate.LinkedIn as LinkedIn
 import qualified Logging as Log
 
 data Config = Config {
@@ -84,17 +90,19 @@
     changeWorkingDirectory (homeDirectory u)
       `catch` (\e -> logException e >> changeWorkingDirectory "/")
 
-  clientSecret <- strip <$> readFile (cfClientSecretFile cf)
   authTokenKey <- B8.unpack . Base64.encode <$> getEntropy 32
   credential <- either error reverseCerts <$> TLS.credentialLoadX509 (cfSslCerts cf) (cfSslKey cf)
 
+  googleCLient <- mkOAuthClient (cfGoogleClientID cf) (cfGoogleClientSecretFile cf)
+  linkedInCLient <- mkOAuthClient (cfLinkedInClientID cf) (cfLinkedInClientSecretFile cf)
+
   let authConfig = AuthConfig {
           authConfigCookieDomain = cfCookieDomain cf
         , authConfigCookieName = cfCookieName cf
-        , authConfigClientID = cfClientID cf
-        , authConfigClientSecret = clientSecret
         , authConfigAuthTokenKey = authTokenKey
         , authConfigShelfLife = CTime . fromIntegral $ cfSessionShelfLife cf
+        , authConfigGoogleClient = googleCLient
+        , authConfigLinkedInClient = linkedInCLient
         }
       config = Config {
           configTLSCredential = credential
@@ -112,6 +120,7 @@
                        ++ show p
     _ -> return () -- XXX can't happen
 
+
   mvar <- newEmptyMVar
 
   void . forkIO $ (runOnSocket sock (serve config authConfig authorize) `catch` logException)
@@ -128,6 +137,15 @@
     -- but the tls library expects them in the opposite order.
     reverseCerts (X509.CertificateChain certs, key) = (X509.CertificateChain $ reverse certs, key)
 
+    mkOAuthClient :: Maybe String -> Maybe FilePath -> IO (Maybe OAuthClient)
+    mkOAuthClient _ Nothing = return Nothing
+    mkOAuthClient Nothing _ = return Nothing
+    mkOAuthClient (Just cid) (Just f) = do
+      sec <- strip <$> readFile f
+      return . Just $ OAuthClient { oauthClientId = B8.pack cid
+                                  , oauthClientSecret = B8.pack sec}
+
+
 -- | Redirects requests to https.
 redirectToHttps :: SockAddr -> Socket -> IO ()
 redirectToHttps _ sock = do
@@ -138,7 +156,7 @@
     Just uri -> do
       let location = uri <> requestPath request
       Log.debug ("Redirecting HTTP request to " ++ show location)
-      simpleResponse send seeOther303 [("Location", location)] ""
+      simpleResponse send movedPermanently301 [("Location", location)] ""
     Nothing -> hostHeaderMissing request >>= sendResponse send
   where
     send = Socket.sendAll sock
@@ -173,45 +191,55 @@
           request@(Request _ path headers _) <- readRequest True conn
           mResponse <- case baseUri (requestHeaders request) of
             Nothing -> Just <$> hostHeaderMissing request
-            Just uri -> do
+            Just base -> do
               let (segments, query) = (decodePath . extractPath) path
               let redirectPath = fromMaybe "/" $ join $ lookup "state" query
               case segments of
                 ["sproxy", "oauth2callback"] ->
                   case join $ lookup "code" query of
                     Nothing -> Just <$> badRequest
-                    Just code -> Just <$> authenticate authConfig uri redirectPath code
-                ["sproxy", "logout"] ->
-                  Just <$> logout authConfig (uri <> redirectPath)
-                -- sproxy sites are private by design. It doesn't make sense to index the authentication page.
+                    Just code -> Just <$> Google.authenticate authConfig base redirectPath code
+                ["sproxy", "oauth2callback", "linkedin"] ->
+                  case join $ lookup "code" query of
+                    Nothing -> Just <$> badRequest
+                    Just code -> Just <$> LinkedIn.authenticate authConfig base redirectPath code
+                ["sproxy", "login"] -> Just <$> loginPage authConfig base redirectPath
+                ["sproxy", "logout"] -> Just <$> logout authConfig (base <> redirectPath)
                 ["robots.txt"] -> Just <$> mkTextResponse ok200 "User-agent: *\nDisallow: /"
                 _ -> -- Check for an auth cookie.
                   case removeCookie (authConfigCookieName authConfig) (parseCookies headers) of
-                    Nothing -> Just <$> redirectForAuth authConfig path uri
+                    Nothing -> Just <$> redirectToLoginPage base path
                     Just (authCookie, cookies) -> do
                       auth <- validAuth authConfig authCookie
                       case auth of
-                        Nothing -> Just <$> redirectForAuth authConfig path uri
+                        Nothing -> Just <$> redirectToLoginPage base path
                         Just token ->
                           forwardRequest config send authorize cookies addr request token
           forM_ mResponse (sendResponse send)
           return ((not . isConnectionClose) headers)
 
 -- Check our access control list for this user's request and forward it to the backend if allowed.
-forwardRequest :: Config -> SendData -> AuthorizeAction -> [(Name, Cookies.Value)] -> SockAddr -> Request BodyReader -> AuthToken -> IO (Maybe (Response BodyReader))
+forwardRequest :: Config
+               -> SendData
+               -> AuthorizeAction
+               -> [(Name, Cookies.Value)]
+               -> SockAddr
+               -> Request BodyReader
+               -> AuthToken
+               -> IO (Maybe (Response BodyReader))
 forwardRequest config send authorize cookies addr request@(Request method path headers _) token = do
-    groups <- authorize (authEmail token) (maybe (error "No Host") cs $ lookup "Host" headers) path method
+    groups <- authorize (authUserEmail . authUser $ token) (maybe (error "No Host") cs $ lookup "Host" headers) path method
     ip <- formatSockAddr addr
     case groups of
-        [] -> Just <$> accessDenied (authEmail token)
+        [] -> Just <$> accessDenied (authUserEmail . authUser $ token)
         _ -> do
             -- TODO: Reuse connections to the backend server.
             let downStreamHeaders =
                     toList $
-                    insert "From" (cs $ authEmail token) $
+                    insert "From" (cs . authUserEmail . authUser $ token) $
                     insert "X-Groups" (cs $ intercalate "," groups) $
-                    insert "X-Given-Name" (cs $ fst $ authName token) $
-                    insert "X-Family-Name" (cs $ snd $ authName token) $
+                    insert "X-Given-Name" (cs . authUserGivenName . authUser $ token) $
+                    insert "X-Family-Name" (cs . authUserFamilyName . authUser $ token) $
                     insert "X-Forwarded-Proto" "https" $
                     addForwardedForHeader ip $
                     insert "Connection" "close" $
