Skip to content

Commit

Permalink
add linkedin!
Browse files Browse the repository at this point in the history
  • Loading branch information
snarfed committed Mar 8, 2019
1 parent d48f3a4 commit d0d909f
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 32 deletions.
44 changes: 12 additions & 32 deletions .gitignore
Expand Up @@ -10,36 +10,16 @@
datastore.dat
oauth_client_secret
private_notes
dropbox_app_key
dropbox_app_secret
facebook_app_id
facebook_app_secret
facebook_app_id_local
facebook_app_secret_local
flickr_app_key
flickr_app_secret
github_client_id
github_client_secret
github_client_id_local
github_client_secret_local
google_client_id
google_client_secret
instagram_client_id
instagram_client_secret
instagram_client_id_local
instagram_client_secret_local
medium_client_id
medium_client_secret
tumblr_app_key
tumblr_app_secret
twitter_app_key
twitter_app_secret
wordpress.com_client_id
wordpress.com_client_secret
wordpress.com_client_id_local
wordpress.com_client_secret_local
disqus_client_id
disqus_client_secret
disqus_client_id_local
disqus_client_secret_local
dropbox_app_*
facebook_app_*
flickr_app_*
github_client_*
google_client_*
instagram_client_*
linkedin_client_*
medium_client_*
tumblr_app_*
twitter_app_*
wordpress.com_*
disqus_client_*
TAGS
3 changes: 3 additions & 0 deletions README.md
Expand Up @@ -314,6 +314,9 @@ you have it as a relative directory. pip requires fully qualified directories.
Changelog
---

### 2.1 - unreleased
* Add LinkedIn!

### 2.0 - 2019-02-25
* _Breaking change_: switch from [Google+ Sign-In](https://developers.google.com/+/web/signin/) ([which shuts down in March](https://developers.google.com/+/api-shutdown)) to [Google Sign-In](https://developers.google.com/identity/). Notably, this removes the `googleplus` module and adds a new `google_signin` module, renames the `GooglePlusAuth` class to `GoogleAuth`, and removes its `api()` method. Otherwise, the implementation is mostly the same.
* webutil.logs: return HTTP 400 if `start_time` is before 2018-04-01 (App Engine's rough launch window).
Expand Down
3 changes: 3 additions & 0 deletions app.py
Expand Up @@ -19,6 +19,7 @@
google_signin,
indieauth,
instagram,
linkedin,
medium,
tumblr,
twitter,
Expand Down Expand Up @@ -61,6 +62,8 @@ def get(self):
('/indieauth/oauth_callback', indieauth.CallbackHandler.to('/')),
('/instagram/start', instagram.StartHandler.to('/instagram/oauth_callback')),
('/instagram/oauth_callback', instagram.CallbackHandler.to('/')),
('/linkedin/start', linkedin.StartHandler.to('/linkedin/oauth_callback')),
('/linkedin/oauth_callback', linkedin.CallbackHandler.to('/')),
('/medium/start', medium.StartHandler.to('/medium/oauth_callback')),
('/medium/oauth_callback', medium.CallbackHandler.to('/')),
('/tumblr/start', tumblr.StartHandler.to('/tumblr/oauth_callback')),
Expand Down
4 changes: 4 additions & 0 deletions docs/source/oauth_dropins.rst
Expand Up @@ -51,6 +51,10 @@ instagram
---------
.. automodule:: oauth_dropins.instagram

linkedin
------
.. automodule:: oauth_dropins.linkedin

medium
------
.. automodule:: oauth_dropins.medium
Expand Down
2 changes: 2 additions & 0 deletions oauth_dropins/appengine_config.py
Expand Up @@ -75,6 +75,8 @@ def read(filename):
INDIEAUTH_CLIENT_ID = read('indieauth_client_id')
INSTAGRAM_SESSIONID_COOKIE = (os.getenv('INSTAGRAM_SESSIONID_COOKIE') or
read('instagram_sessionid_cookie'))
LINKEDIN_CLIENT_ID = read('linkedin_client_id')
LINKEDIN_CLIENT_SECRET = read('linkedin_client_secret')
MEDIUM_CLIENT_ID = read('medium_client_id')
MEDIUM_CLIENT_SECRET = read('medium_client_secret')
TUMBLR_APP_KEY = read('tumblr_app_key')
Expand Down
165 changes: 165 additions & 0 deletions oauth_dropins/linkedin.py
@@ -0,0 +1,165 @@
"""LinkedIn OAuth drop-in.
API docs:
https://www.linkedin.com/developers/
https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin
"""
import json
import logging
import urllib

import appengine_config
import handlers
from models import BaseAuth
from webutil import util

from google.appengine.ext import ndb
from webob import exc

# URL templates. Can't (easily) use urlencode() because I want to keep
# the %(...)s placeholders as is and fill them in later in code.
AUTH_CODE_URL = str('&'.join((
'https://www.linkedin.com/oauth/v2/authorization?'
'response_type=code',
'client_id=%(client_id)s',
# https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api?context=linkedin/consumer/context#permissions
'scope=%(scope)s',
# must be the same in the access token request
'redirect_uri=%(redirect_uri)s',
'state=%(state)s',
)))

ACCESS_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'
API_PROFILE_URL = 'https://api.linkedin.com/v2/me'


class LinkedInAuth(BaseAuth):
"""An authenticated LinkedIn user.
Provides methods that return information about this user and make OAuth-signed
requests to the LinkedIn REST API. Stores OAuth credentials in the datastore.
See models.BaseAuth for usage details.
LinkedIn-specific details: TODO
implements get() but not urlopen(), http(), or api().
The key name is the ID (a URN).
Note that LI access tokens can be over 500 chars (up to 1k!), so they need to
be TextProperty instead of StringProperty.
https://docs.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?context=linkedin/consumer/context#access-token-response
"""
access_token_str = ndb.TextProperty(required=True)
user_json = ndb.TextProperty()

def site_name(self):
return 'LinkedIn'

def user_display_name(self):
"""Returns the user's first and last name.
"""
def name(field):
user = json.loads(self.user_json)
loc = user.get(field, {}).get('localized', {})
if loc:
return loc.get('en_US') or loc.values()[0]
return ''

return '%s %s' % (name('firstName'), name('lastName'))

def access_token(self):
"""Returns the OAuth access token string.
"""
return self.access_token_str

def get(self, *args, **kwargs):
"""Wraps requests.get() and adds the Bearer token header.
TODO: unify with github.py, medium.py.
"""
return self._requests_call(util.requests_get, *args, **kwargs)

def post(self, *args, **kwargs):
"""Wraps requests.post() and adds the Bearer token header.
TODO: unify with github.py, medium.py.
"""
return self._requests_call(util.requests_post, *args, **kwargs)

def _requests_call(self, fn, *args, **kwargs):
headers = kwargs.setdefault('headers', {})
headers['Authorization'] = 'Bearer ' + self.access_token_str

resp = fn(*args, **kwargs)
assert 'serviceErrorCode' not in resp, resp

try:
resp.raise_for_status()
except BaseException, e:
util.interpret_http_exception(e)
raise
return resp


class StartHandler(handlers.StartHandler):
"""Starts LinkedIn auth. Requests an auth code and expects a redirect back.
"""
DEFAULT_SCOPE = 'r_liteprofile'

def redirect_url(self, state=None):
# assert state, 'LinkedIn OAuth 2 requires state parameter'
assert (appengine_config.LINKEDIN_CLIENT_ID and
appengine_config.LINKEDIN_CLIENT_SECRET), (
"Please fill in the linkedin_client_id and "
"linkedin_client_secret files in your app's root directory.")
return str(AUTH_CODE_URL % {
'client_id': appengine_config.LINKEDIN_CLIENT_ID,
'redirect_uri': urllib.quote_plus(self.to_url()),
'state': urllib.quote_plus(state or ''),
'scope': self.scope,
})


class CallbackHandler(handlers.CallbackHandler):
"""The OAuth callback. Fetches an access token and stores it.
"""
def get(self):
# handle errors
error = self.request.get('error')
desc = self.request.get('error_description')
if error:
# https://docs.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?context=linkedin/consumer/context#application-is-rejected
if error in ('user_cancelled_login', 'user_cancelled_authorize'):
logging.info('User declined: %s', self.request.get('error_description'))
self.finish(None, state=self.request.get('state'))
return
else:
msg = 'Error: %s: %s' % (error, desc)
logging.info(msg)
raise exc.HTTPBadRequest(msg)

# extract auth code and request access token
auth_code = util.get_required_param(self, 'code')
data = {
'grant_type': 'authorization_code',
'code': auth_code,
'client_id': appengine_config.LINKEDIN_CLIENT_ID,
'client_secret': appengine_config.LINKEDIN_CLIENT_SECRET,
# redirect_uri here must be the same in the oauth code request!
# (the value here doesn't actually matter since it's requested server side.)
'redirect_uri': self.request.path_url,
}
resp = util.requests_post(ACCESS_TOKEN_URL, data=urllib.urlencode(data)).json()
logging.debug('Access token response: %s', resp)
if resp.get('serviceErrorCode'):
msg = 'Error: %s' % resp
logging.info(msg)
raise exc.HTTPBadRequest(msg)

access_token = resp['access_token']
resp = LinkedInAuth(access_token_str=access_token).get(API_PROFILE_URL).json()
logging.debug('Profile response: %s', resp)
auth = LinkedInAuth(id=resp['id'], access_token_str=access_token,
user_json=json.dumps(resp))
auth.put()

self.finish(auth, state=self.request.get('state'))

0 comments on commit d0d909f

Please sign in to comment.