├── .gitignore ├── .gitmodules ├── README.md ├── alltests.py ├── app.py ├── app.yaml ├── app.yaml.facebook ├── app.yaml.twitter ├── appengine_config.py ├── dateutil ├── django_salmon ├── static ├── crossed_fingers.png └── pointing_finger.png ├── templates ├── host-meta.jrd ├── host-meta.xrd ├── index.html ├── user.jrd ├── user.xrd ├── webfist.jrd └── webfist.xrd ├── tweepy ├── user.py └── user_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | twitter_access_token_key 2 | twitter_access_token_secret 3 | twitter_app_key 4 | twitter_app_secret 5 | user_key_handler_secret 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "webutil"] 2 | path = webutil 3 | url = git@github.com:snarfed/webutil.git 4 | [submodule "django-salmon"] 5 | path = django-salmon 6 | url = git@github.com:snarfed/django-salmon.git 7 | [submodule "python-dateutil"] 8 | path = python-dateutil 9 | url = git://github.com/paxan/python-dateutil.git 10 | [submodule "tweepy_submodule"] 11 | path = tweepy_submodule 12 | url = git://github.com/tweepy/tweepy.git 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | webfinger-unofficial ![Webfinger](https://raw.github.com/snarfed/webfinger-unofficial/master/static/pointing_finger.png) 2 | === 3 | 4 | * [About](#about) 5 | * [Using](#using) 6 | * [Future work](#future-work) 7 | * [Development](#development) 8 | 9 | 10 | About 11 | --- 12 | 13 | This is a [WebFinger](http://code.google.com/p/webfinger/) server for Facebook and Twitter. It's deployed at these endpoints: 14 | 15 | http://facebook-webfinger.appspot.com/ 16 | http://twitter-webfinger.appspot.com/ 17 | 18 | It's part of a suite of projects that implement the [OStatus](http://ostatus.org/) federation protocols for the major social networks. The other projects include [activitystreams-](https://github.com/snarfed/activitystreams-unofficial), [portablecontacts-](https://github.com/snarfed/portablecontacts-unofficial), [salmon-](https://github.com/snarfed/salmon-unofficial), and [ostatus-unofficial](https://github.com/snarfed/ostatus-unofficial). 19 | 20 | Google isn't included because it already provides a WebFinger server for Google accounts at `gmail.com`. 21 | 22 | License: This project is placed in the public domain. 23 | 24 | 25 | Using 26 | --- 27 | 28 | This simply implements the [WebFinger protocol](http://code.google.com/p/webfinger/wiki/WebFingerProtocol) using Facebook's and Twitter's OAuth authentication and APIs. To use it, just point your WebFinger client code at the [endpoints above](#about). 29 | 30 | If your client consumes arbitrary email addresses, you'll need to hard-code exceptions for `facebook.com` and `twitter.com` and redirect HTTP requests to these endpoints. (The user URI may use either domain, e.g. `snarfed.org@facebook.com` or `snarfed.org@facebook-webfinger.appspot.com`.) 31 | 32 | 33 | Future work 34 | --- 35 | 36 | This should be refactored so it can be used as a library, like [activitystreams-unofficial](https://github.com/snarfed/activitystreams-unofficial). 37 | 38 | We'd also love to add more sites! Off the top of my head, [Yahoo](http://yahoo.com/), [Microsoft](https://login.live.com/), [Amazon](http://login.amazon.com/), [Apple's iCloud](https://www.icloud.com/), [Instagram](http://instagram.com/developer/), [WordPress.com](http://wordpress.com/), and [Sina Weibo](http://en.wikipedia.org/wiki/Sina_Weibo) would be good candidates. If you're looking to get started, implementing a new site is a good place to start. It's pretty self contained and the existing sites are good examples to follow, but it's a decent amount of work, so you'll be familiar with the whole project by the end. 39 | 40 | 41 | Development 42 | --- 43 | 44 | Pull requests are welcome! Feel free to [ping me](http://snarfed.org/about) with any questions. 45 | 46 | Most dependencies are included as git submodules. Be sure to run `git submodule init` after cloning this repo. 47 | 48 | You can run the unit tests with `./alltests.py`. They depend on the [App Engine SDK](https://developers.google.com/appengine/downloads) and [mox](http://code.google.com/p/pymox/), both of which you'll need to install yourself. 49 | 50 | Note the `app.yaml.*` files, one for each App Engine app id. To work on or deploy a specific app id, `symlink app.yaml` to its `app.yaml.xxx` file. Likewise, if you add a new site, you'll need to add a corresponding `app.yaml.xxx` file. 51 | 52 | To deploy: 53 | 54 | ```shell 55 | rm -f app.yaml && ln -s app.yaml.twitter app.yaml && \ 56 | ~/google_appengine/appcfg.py --oauth2 update . && \ 57 | rm -f app.yaml && ln -s app.yaml.facebook app.yaml && \ 58 | ~/google_appengine/appcfg.py --oauth2 update . 59 | ``` 60 | -------------------------------------------------------------------------------- /alltests.py: -------------------------------------------------------------------------------- 1 | webutil/alltests.py -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Serves the HTML front page and discovery files. 3 | """ 4 | 5 | __author__ = 'Ryan Barrett ' 6 | 7 | import appengine_config 8 | from webutil import handlers 9 | 10 | from google.appengine.ext import webapp 11 | from google.appengine.ext.webapp.util import run_wsgi_app 12 | 13 | 14 | class FrontPageHandler(handlers.TemplateHandler): 15 | """Renders and serves /, ie the front page. 16 | """ 17 | def template_file(self): 18 | return 'templates/index.html' 19 | 20 | def template_vars(self): 21 | return {'domain': appengine_config.DOMAIN} 22 | 23 | 24 | def main(): 25 | application = webapp.WSGIApplication( 26 | [('/', FrontPageHandler)] + handlers.HOST_META_ROUTES, 27 | debug=appengine_config.DEBUG) 28 | run_wsgi_app(application) 29 | 30 | 31 | if __name__ == '__main__': 32 | main() 33 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | app.yaml.twitter -------------------------------------------------------------------------------- /app.yaml.facebook: -------------------------------------------------------------------------------- 1 | application: facebook-webfinger 2 | version: 1 3 | runtime: python27 4 | threadsafe: false 5 | api_version: 1 6 | default_expiration: 1d 7 | 8 | libraries: 9 | - name: django 10 | version: "1.2" 11 | - name: pycrypto 12 | version: "2.3" 13 | 14 | handlers: 15 | - url: /static 16 | static_dir: static 17 | 18 | - url: (/|/.well-known/host-meta)(\.json)? 19 | script: app.py 20 | secure: always 21 | 22 | - url: (/.well-known/webfinger)(\.json)? 23 | script: user.py 24 | secure: always 25 | 26 | - url: /user(\.json|_key)? 27 | script: user.py 28 | secure: always 29 | 30 | skip_files: 31 | - ^(.*/)?.*\.py[co] 32 | - ^(.*/)?.*/RCS/.* 33 | - ^(.*/)?\..* 34 | - ^(.*/)?.*\.bak$ 35 | # don't need anything in the webapp-improved subdirs, especially since 36 | # webapp-improved/lib/ has over 1k files! 37 | - webutil/webapp-improved/.*/.* 38 | -------------------------------------------------------------------------------- /app.yaml.twitter: -------------------------------------------------------------------------------- 1 | application: twitter-webfinger 2 | version: 1 3 | runtime: python27 4 | threadsafe: false 5 | api_version: 1 6 | default_expiration: 1d 7 | 8 | libraries: 9 | - name: django 10 | version: "1.2" 11 | - name: pycrypto 12 | version: "2.3" 13 | 14 | handlers: 15 | - url: /static 16 | static_dir: static 17 | 18 | - url: (/|/.well-known/host-meta)(\.json)? 19 | script: app.py 20 | secure: always 21 | 22 | - url: (/.well-known/webfinger)(\.json)? 23 | script: user.py 24 | secure: always 25 | 26 | - url: /user(\.json|_key)? 27 | script: user.py 28 | secure: always 29 | 30 | skip_files: 31 | - ^(.*/)?.*\.py[co] 32 | - ^(.*/)?.*/RCS/.* 33 | - ^(.*/)?\..* 34 | - ^(.*/)?.*\.bak$ 35 | # don't need anything in the webapp-improved subdirs, especially since 36 | # webapp-improved/lib/ has over 1k files! 37 | - webutil/webapp-improved/.*/.* 38 | -------------------------------------------------------------------------------- /appengine_config.py: -------------------------------------------------------------------------------- 1 | """App Engine settings. 2 | """ 3 | 4 | from webutil.appengine_config import * 5 | 6 | # maps app id to domain 7 | DOMAINS = { 8 | 'facebook-webfinger': 'facebook.com', 9 | 'twitter-webfinger': 'twitter.com', 10 | } 11 | 12 | DOMAIN = DOMAINS.get(APP_ID) 13 | 14 | USER_KEY_HANDLER_SECRET = read('user_key_handler_secret') 15 | -------------------------------------------------------------------------------- /dateutil: -------------------------------------------------------------------------------- 1 | python-dateutil/dateutil -------------------------------------------------------------------------------- /django_salmon: -------------------------------------------------------------------------------- 1 | django-salmon/django_salmon -------------------------------------------------------------------------------- /static/crossed_fingers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snarfed/webfinger-unofficial/c8c0759261141314818607b69d1baf9e7f8a2f22/static/crossed_fingers.png -------------------------------------------------------------------------------- /static/pointing_finger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snarfed/webfinger-unofficial/c8c0759261141314818607b69d1baf9e7f8a2f22/static/pointing_finger.png -------------------------------------------------------------------------------- /templates/host-meta.jrd: -------------------------------------------------------------------------------- 1 | {"links": [ 2 | { 3 | "rel": "lrdd", 4 | "type": "application/json", 5 | "template": "https://{{ host }}/user?uri={uri}&format=json" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /templates/host-meta.xrd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | WebFinger for {{ domain }} 8 | 9 | 10 | 11 |

WebFinger for {{ domain }}

12 | 13 | 14 | 15 | 16 |

This is an unofficial 17 | WebFinger server 18 | for {{ domain }}. It serves these endpoints:

19 | 24 |

The user URI is acct:username@{{ domain }}, 25 | where username is a username or user id. See the 26 | WebFinger docs 27 | for usage details.

28 | 29 |

Source: github.com/snarfed/webfinger-unofficial

30 | 31 |

Questions? Contact Ryan or the 32 | WebFinger 33 | mailing list.

34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /templates/user.jrd: -------------------------------------------------------------------------------- 1 | { 2 | "subject": "{{ uri }}", 3 | "aliases": ["{{ profile_url }}"], 4 | 5 | {% if magic_public_key %} 6 | "magic_keys": [{"value": "{{ magic_public_key }}"}], 7 | {% endif %} 8 | 9 | "links": [ 10 | { 11 | "rel": "http://webfinger.net/rel/profile-page", 12 | "type": "text/html", 13 | "href": "{{ profile_url }}" 14 | }, 15 | 16 | { 17 | "rel": "describedby", 18 | "type": "text/html", 19 | "href": "{{ profile_url }}" 20 | }, 21 | 22 | {% if magic_public_key %} 23 | { 24 | "rel": "magic-public-key", 25 | "href": "{{ magic_public_key }}" 26 | }, 27 | {% endif %} 28 | 29 | {% if picture_url %} 30 | { 31 | "rel": "http://webfinger.net/rel/avatar", 32 | "href": "{{ picture_url }}" 33 | }, 34 | {% endif %} 35 | 36 | {% if openid_url %} 37 | { 38 | "rel": "http://specs.openid.net/auth/2.0/provider", 39 | "href": "{{ openid_url }}" 40 | }, 41 | {% endif %} 42 | 43 | {% if hcard_url %} 44 | { 45 | "rel": "http://microformats.org/profile/hcard", 46 | "href": "{{ hcard_url }}" 47 | }, 48 | {% endif %} 49 | 50 | {% if xfn_url %} 51 | { 52 | "rel": "http://gmpg.org/xfn/11", 53 | "href": "{{ xfn_url }}" 54 | }, 55 | {% endif %} 56 | 57 | {% if poco_url %} 58 | { 59 | "rel": "http://portablecontacts.net/spec/1.0", 60 | "href": "{{ poco_url }}" 61 | }, 62 | {% endif %} 63 | 64 | {% if activitystreams_url %} 65 | { 66 | "rel": "http://ns.opensocial.org/2008/opensocial/activitystreams", 67 | "href": "{{ activitystreams_url }}" 68 | }, 69 | 70 | { 71 | "rel": "http://activitystrea.ms/spec/1.0", 72 | "href": "{{ activitystreams_url }}" 73 | } 74 | {% endif %} 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /templates/user.xrd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ uri }} 5 | {{ profile_url }} 6 | 7 | 9 | 11 | 12 | {% if magic_public_key %} 13 | 15 | {{ magic_public_key }} 16 | 17 | 19 | {% endif %} 20 | 21 | {% if picture_url %} 22 | 24 | {% endif %} 25 | 26 | {% if openid_url %} 27 | 29 | {% endif %} 30 | 31 | {% if xfn_url %} 32 | 34 | {% endif %} 35 | 36 | {% if hcard_url %} 37 | 39 | {% endif %} 40 | 41 | {% if poco_url %} 42 | 44 | {% endif %} 45 | 46 | {% if activitystreams_url %} 47 | 49 | 51 | {% endif %} 52 | 53 | -------------------------------------------------------------------------------- /templates/webfist.jrd: -------------------------------------------------------------------------------- 1 | { 2 | "subject": "{{ username }}@{{ domain }}", 3 | "links": [ 4 | { 5 | "rel": "http://webfist.org/spec/rel", 6 | "href": "https://{{ host }}/user.json?uri={{ uri }}", 7 | "properties": {} 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /templates/webfist.xrd: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ username }}@{{ domain }} 4 | 6 | 7 | -------------------------------------------------------------------------------- /tweepy: -------------------------------------------------------------------------------- 1 | tweepy_submodule/tweepy -------------------------------------------------------------------------------- /user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Serves user LRDD files at the /user endpoint. 3 | 4 | Includes Magic Signatures public keys. Details: 5 | http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-magicsig-01.html 6 | """ 7 | 8 | __author__ = 'Ryan Barrett ' 9 | 10 | import json 11 | import urllib2 12 | 13 | import appengine_config 14 | 15 | import logging 16 | import os 17 | import urllib 18 | import urlparse 19 | from webob import exc 20 | from webutil import handlers 21 | from webutil import util 22 | 23 | from django_salmon import magicsigs 24 | from google.appengine.ext import db 25 | from google.appengine.ext.webapp.util import run_wsgi_app 26 | import tweepy 27 | import webapp2 28 | 29 | TWITTER_ACCESS_TOKEN_KEY = appengine_config.read('twitter_access_token_key') 30 | TWITTER_ACCESS_TOKEN_SECRET = appengine_config.read('twitter_access_token_secret') 31 | 32 | 33 | class User(db.Model): 34 | """Stores a user's public/private key pair used for Magic Signatures. 35 | 36 | The key name is the user URI, including the acct: prefix. 37 | 38 | The modulus and exponent properties are all encoded as base64url (ie URL-safe 39 | base64) strings as described in RFC 4648 and section 5.1 of the Magic 40 | Signatures spec. 41 | 42 | Magic Signatures are used to sign Salmon slaps. Details: 43 | http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-magicsig-01.html 44 | http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-salmon-00.html 45 | """ 46 | mod = db.StringProperty(required=True) 47 | public_exponent = db.StringProperty(required=True) 48 | private_exponent = db.StringProperty(required=True) 49 | 50 | @staticmethod 51 | @db.transactional 52 | def get_or_create(uri): 53 | """Loads and returns a User from the datastore. Creates it if necessary.""" 54 | user = User.get_by_key_name(uri) 55 | 56 | if not user: 57 | # this uses urandom(), and does some nontrivial math, so it can take a 58 | # while depending on the amount of randomness available on the system. 59 | pubexp, mod, privexp = magicsigs.generate() 60 | user = User(key_name=uri, mod=mod, public_exponent=pubexp, 61 | private_exponent=privexp) 62 | user.put() 63 | 64 | return user 65 | 66 | 67 | class BaseHandler(handlers.XrdOrJrdHandler): 68 | """Renders and serves WebFist /.well-known/webfinger?resource=... requests. 69 | """ 70 | 71 | def template_prefix(self): 72 | return 'templates/webfist' 73 | 74 | def is_jrd(self): 75 | """WebFist is always JSON. (I think?) 76 | """ 77 | return True 78 | 79 | def template_vars(self): 80 | # parse and validate user uri 81 | uri = self.request.get('uri') or self.request.get('resource') 82 | if not uri: 83 | raise exc.HTTPBadRequest('Missing uri query parameter.') 84 | 85 | try: 86 | allowed_domains = (appengine_config.HOST, appengine_config.DOMAIN) 87 | username, domain = util.parse_acct_uri(uri, allowed_domains) 88 | except ValueError, e: 89 | raise exc.HTTPBadRequest(e.message) 90 | 91 | return { 92 | 'domain': domain, 93 | 'host': appengine_config.HOST, 94 | 'uri': uri, 95 | 'username': username, 96 | } 97 | 98 | 99 | class UserHandler(BaseHandler): 100 | """Renders and serves /user?uri=... requests. 101 | """ 102 | 103 | def template_prefix(self): 104 | return 'templates/user' 105 | 106 | def is_jrd(self): 107 | return handlers.XrdOrJrdHandler.is_jrd(self) 108 | 109 | def template_vars(self): 110 | vars = super(UserHandler, self).template_vars() 111 | 112 | user = User.get_or_create(vars['uri']) 113 | vars['magic_public_key'] = 'RSA.%s.%s' % (user.mod, user.public_exponent) 114 | 115 | username = vars['username'] 116 | if appengine_config.APP_ID == 'facebook-webfinger': 117 | vars.update({ 118 | 'profile_url': 'http://www.facebook.com/%s' % username, 119 | 'picture_url': 'http://graph.facebook.com/%s/picture' % username, 120 | 'openid_url': 'http://facebook-openid.appspot.com/%s' % username, 121 | 'poco_url': 'https://facebook-poco.appspot.com/poco/', 122 | 'activitystreams_url': 'https://facebook-activitystreams.appspot.com/', 123 | }) 124 | return vars 125 | elif appengine_config.APP_ID == 'twitter-webfinger': 126 | profile_url = 'http://twitter.com/%s' % username 127 | vars.update({ 128 | 'profile_url': profile_url, 129 | 'hcard_url': profile_url, 130 | 'xfn_url': profile_url, 131 | 'poco_url': 'https://twitter-poco.appspot.com/poco/', 132 | 'activitystreams_url': 'https://twitter-activitystreams.appspot.com/', 133 | }) 134 | 135 | # fetch the image URL. it'd be way easier to pass back the api.twitter.com 136 | # URL itself, since it 302 redirects, but twitter explicitly says we 137 | # shouldn't do that. :/ ah well. 138 | # https://dev.twitter.com/docs/api/1/get/users/profile_image/%3Ascreen_name 139 | try: 140 | url = 'https://api.twitter.com/1.1/users/show.json' 141 | params = {'screen_name': username} 142 | auth = tweepy.OAuthHandler(appengine_config.TWITTER_APP_KEY, 143 | appengine_config.TWITTER_APP_SECRET) 144 | # make sure token key and secret aren't unicode because python's hmac 145 | # module (used by tweepy/oauth.py) expects strings. 146 | # http://stackoverflow.com/questions/11396789 147 | auth.set_access_token(str(TWITTER_ACCESS_TOKEN_KEY), 148 | str(TWITTER_ACCESS_TOKEN_SECRET)) 149 | headers = {} 150 | auth.apply_auth(url, 'GET', headers, params) 151 | logging.info('Populated Authorization header from access token: %s', 152 | headers.get('Authorization')) 153 | req = urllib2.Request(url + '?' + urllib.urlencode(params), 154 | headers=headers) 155 | resp = json.loads(urllib2.urlopen(req).read()) 156 | vars['picture_url'] = resp.get('profile_image_url') 157 | except: 158 | logging.exception('Error while fetching %s' % url) 159 | 160 | return vars 161 | 162 | else: 163 | raise exc.HTTPInternalServerError('Unknown app id %s.' % 164 | appengine_config.APP_ID) 165 | 166 | 167 | class UserKeyHandler(webapp2.RequestHandler): 168 | """Serves users' Magic Signature private keys. 169 | 170 | The response is a JSON object with public_exponent, private_exponent, and mod. 171 | """ 172 | 173 | def get(self): 174 | if self.request.get('secret') != appengine_config.USER_KEY_HANDLER_SECRET: 175 | raise exc.HTTPForbidden() 176 | 177 | user = User.get_or_create(self.request.get('uri')) 178 | self.response.headers['Content-Type'] = 'application/json' 179 | self.response.out.write(json.dumps(db.to_dict(user), indent=2)) 180 | 181 | 182 | application = webapp2.WSGIApplication( 183 | [('/(?:.well-known/webfinger)(?:\.json)?', BaseHandler), 184 | ('/user(?:\.json)?', UserHandler), 185 | ('/user_key', UserKeyHandler), 186 | ], 187 | debug=appengine_config.DEBUG) 188 | 189 | 190 | def main(): 191 | run_wsgi_app(application) 192 | 193 | if __name__ == '__main__': 194 | main() 195 | -------------------------------------------------------------------------------- /user_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | """Unit tests for user.py. 3 | """ 4 | 5 | __author__ = ['Ryan Barrett '] 6 | 7 | try: 8 | import json 9 | except ImportError: 10 | import simplejson as json 11 | import mox 12 | import urllib2 13 | from webob import exc 14 | 15 | import appengine_config 16 | import user 17 | from webutil import testutil 18 | from webutil import webapp2 19 | 20 | 21 | class UserHandlerTest(testutil.HandlerTest): 22 | 23 | def setUp(self): 24 | super(UserHandlerTest, self).setUp() 25 | appengine_config.APP_ID = 'facebook-webfinger' 26 | appengine_config.DOMAIN = 'facebook.com' 27 | appengine_config.USER_KEY_HANDLER_SECRET = 'secret' 28 | self.response = webapp2.Response() 29 | 30 | def test_no_uri_error(self): 31 | resp = user.application.get_response('/user') 32 | self.assertEquals(400, resp.status_int) 33 | 34 | def test_uri_scheme_not_acct_error(self): 35 | resp = user.application.get_response('/user?uri=mailto:ryan@facebook.com') 36 | self.assertEquals(400, resp.status_int) 37 | 38 | def test_bad_uri_format_error(self): 39 | resp = user.application.get_response('/user?uri=acct:foo') 40 | self.assertEquals(400, resp.status_int) 41 | 42 | def test_uri_wrong_domain_error(self): 43 | resp = user.application.get_response('/user?uri=acct:ryan@xyz.com') 44 | self.assertEquals(400, resp.status_int) 45 | 46 | def test_facebook(self): 47 | req = webapp2.Request.blank('/user.json?uri=acct:ryan@facebook.com') 48 | handler = user.UserHandler(req, self.response) 49 | 50 | vars = handler.template_vars() 51 | ryan = user.User.get_by_key_name('acct:ryan@facebook.com') 52 | self.assert_equals( 53 | {'username': 'ryan', 54 | 'domain': 'facebook.com', 55 | 'profile_url': 'http://www.facebook.com/ryan', 56 | 'picture_url': 'http://graph.facebook.com/ryan/picture', 57 | 'openid_url': 'http://facebook-openid.appspot.com/ryan', 58 | 'poco_url': 'https://facebook-poco.appspot.com/poco/', 59 | 'activitystreams_url': 'https://facebook-activitystreams.appspot.com/', 60 | 'uri': 'acct:ryan@facebook.com', 61 | 'magic_public_key': 'RSA.%s.%s' % (ryan.mod, ryan.public_exponent), 62 | }, 63 | vars) 64 | 65 | 66 | def test_twitter(self): 67 | self.expect_urlopen('https://api.twitter.com/1.1/users/show.json?screen_name=ryan', 68 | json.dumps({'profile_image_url': 'http://pic/ture'})) 69 | self.mox.ReplayAll() 70 | 71 | appengine_config.APP_ID = 'twitter-webfinger' 72 | appengine_config.DOMAIN = 'twitter.com' 73 | 74 | req = webapp2.Request.blank('/user.json?uri=acct:ryan@twitter.com') 75 | handler = user.UserHandler(req, self.response) 76 | vars = handler.template_vars() 77 | ryan = user.User.get_by_key_name('acct:ryan@twitter.com') 78 | self.assert_equals( 79 | {'username': 'ryan', 80 | 'domain': 'twitter.com', 81 | 'profile_url': 'http://twitter.com/ryan', 82 | 'hcard_url': 'http://twitter.com/ryan', 83 | 'xfn_url': 'http://twitter.com/ryan', 84 | 'poco_url': 'https://twitter-poco.appspot.com/poco/', 85 | 'activitystreams_url': 'https://twitter-activitystreams.appspot.com/', 86 | 'uri': 'acct:ryan@twitter.com', 87 | 'picture_url': 'http://pic/ture', 88 | 'magic_public_key': 'RSA.%s.%s' % (ryan.mod, ryan.public_exponent), 89 | }, 90 | vars) 91 | 92 | def test_twitter_profile_image_urlopen_fails(self): 93 | url = 'https://api.twitter.com/1.1/users/show.json?screen_name=ryan' 94 | urllib2.urlopen(mox.Func(lambda req: req.get_full_url() == url), 95 | timeout=999).AndRaise(urllib2.URLError('')) 96 | self.mox.ReplayAll() 97 | 98 | appengine_config.APP_ID = 'twitter-webfinger' 99 | appengine_config.DOMAIN = 'twitter.com' 100 | 101 | req = webapp2.Request.blank('/user.json?uri=acct:ryan@twitter.com') 102 | handler = user.UserHandler(req, self.response) 103 | self.assertEquals(None, handler.template_vars().get('picture_url')) 104 | 105 | def test_keypair_is_persistent(self): 106 | req = webapp2.Request.blank('/user.json?uri=acct:ryan@facebook.com') 107 | handler = user.UserHandler(req, self.response) 108 | 109 | first = handler.template_vars() 110 | second = handler.template_vars() 111 | self.assertEqual(first['magic_public_key'], second['magic_public_key']) 112 | 113 | def test_user_key_handler(self): 114 | resp = user.application.get_response('/user_key?uri=acct:ryan&secret=secret') 115 | ryan = user.User.get_by_key_name('acct:ryan') 116 | self.assertEqual(200, resp.status_int) 117 | self.assertEqual({ 118 | 'public_exponent': ryan.public_exponent, 119 | 'private_exponent': ryan.private_exponent, 120 | 'mod': ryan.mod 121 | }, json.loads(resp.body)) 122 | 123 | def test_user_key_handler_bad_secret(self): 124 | user.User(key_name='acct:ryan@facebook.com', 125 | public_exponent='123', private_exponent='456', mod='789').save() 126 | resp = user.application.get_response( 127 | '/user_key?uri=acct:ryan@facebook.com&secret=bad') 128 | self.assertEqual(403, resp.status_int) 129 | --------------------------------------------------------------------------------