Skip to content

Commit

Permalink
IN PROGRESS: add Bluesky
Browse files Browse the repository at this point in the history
  • Loading branch information
snarfed committed May 26, 2023
1 parent 283397d commit 3757f02
Show file tree
Hide file tree
Showing 3 changed files with 314 additions and 1 deletion.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Drop-in Python [OAuth](http://oauth.net/) for popular sites!
About
---

This is a collection of drop-in Python [Flask](https://flask.palletsprojects.com/) views for the initial [OAuth](http://oauth.net/) client flows for many popular sites, including Blogger, Disqus, Dropbox, Facebook, Flickr, GitHub, Google, IndieAuth, Instagram, LinkedIn, Mastodon, Medium, Tumblr, Twitter, and WordPress.com.
This is a collection of drop-in Python [Flask](https://flask.palletsprojects.com/) views for the initial [OAuth](http://oauth.net/) client flows for many popular sites, including Blogger, Bluesky/AT Protocol, Disqus, Dropbox, Facebook, Flickr, GitHub, Google, IndieAuth, Instagram, LinkedIn, Mastodon, Medium, Tumblr, Twitter, and WordPress.com.

oauth-dropins stores user credentials in [Google Cloud Datastore](https://cloud.google.com/datastore/). It's primarily designed for [Google App Engine](https://appengine.google.com/), but it can be used in any Python web application, regardless of host or framework.

Expand Down Expand Up @@ -151,6 +151,12 @@ Troubleshooting/FAQ
Changelog
---

### 6.2 - unreleased

_Non-breaking changes:_

* Add Bluesky/AT Protocol. Unusual addition because it's technically not (yet) OAuth.

### 6.1 - 2023-03-22

_Non-breaking changes:_
Expand Down
306 changes: 306 additions & 0 deletions oauth_dropins/bluesky.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
"""Bluesky/AT Protocol OAuth drop-in.
https://atproto.com/docs
Requires app passwords: https://atproto.com/specs/atp#app-passwords
"""
import logging
from urllib.parse import quote_plus, unquote, urlencode, urljoin, urlparse, urlunparse

from flask import request
from google.cloud import ndb
import requests

from . import views
from .models import BaseAuth
from .webutil import appengine_info, flask_util, util
from .webutil.util import json_dumps, json_loads

logger = logging.getLogger(__name__)

DOMAINS = (
'bsky.app',
'staging.bsky.app',
)


class BlueskyAuth(BaseAuth):
"""An authenticated Bluesky user.
Provides methods that return information about this user and make OAuth-signed
requests to the Bluesky REST API. Stores OAuth credentials in the datastore.
See models.BaseAuth for usage details.
Key name is the user's handle, eg alice.bsky.social
Implements get() and post() but not urlopen() or api().
"""
app = ndb.KeyProperty()
access_token_str = ndb.StringProperty(required=True)
user_json = ndb.TextProperty()

def site_name(self):
return 'Bluesky'

def user_display_name(self):
"""Returns the user's full ActivityPub address, eg @ryan@mastodon.social."""
return self.key.id()

def instance(self):
"""Returns the instance base URL, eg https://mastodon.social/."""
return self.app.get().instance

def username(self):
"""Returns the user's username, eg ryan."""
return json_loads(self.user_json).get('username')

def user_id(self):
"""Returns the user's id, eg 123."""
return json_loads(self.user_json).get('id')

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 instance base URL and Bearer token header."""
url = urljoin(self.instance(), args[0])
return self._requests_call(util.requests_get, url, *args[1:], **kwargs)

def post(self, *args, **kwargs):
"""Wraps requests.post() and adds the Bearer token header."""
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)
try:
resp.raise_for_status()
except BaseException as e:
util.interpret_http_exception(e)
raise
return resp


class Start(views.Start):
"""Starts Bluesky auth. Requests an auth code and expects a redirect back.
Attributes:
DEFAULT_SCOPE: string, default OAuth scope(s) to request
REDIRECT_PATHS: sequence of string URL paths (on this host) to register as
OAuth callback (aka redirect) URIs in the OAuth app
SCOPE_SEPARATOR: string, used to separate multiple scopes
APP_CLASS: API app datastore class
EXPIRE_APPS_BEFORE: datetime, if the API client app was created before this,
it will be discarded and a new one will be created. Set to the last time
you changed something material about the client, eg redirect URLs or scopes.
"""
NAME = 'bluesky'
LABEL = 'Bluesky'
DEFAULT_SCOPE = 'read:accounts'
REDIRECT_PATHS = ()
SCOPE_SEPARATOR = ' '
APP_CLASS = BlueskyApp
# https://github.com/snarfed/bridgy/issues/1344
EXPIRE_APPS_BEFORE = None

def app_name(self):
"""Returns the user-visible name of this application.
To be overridden by subclasses. Displayed in Bluesky's OAuth prompt.
"""
return 'oauth-dropins demo'

def app_url(self):
"""Returns this application's web site.
To be overridden by subclasses. Displayed in Bluesky's OAuth prompt.
"""
# normalize trailing slash. oddly sometimes request.host_url has it,
# sometimes it doesn't.
return urljoin(request.host_url, '/')

@classmethod
def _version_ok(cls, version):
return 'Pixelfed' not in version

def redirect_url(self, state=None, instance=None):
"""Returns the local URL for Bluesky to redirect back to after OAuth prompt.
Args:
state: string, user-provided value to be returned as a query parameter in
the return redirect
instance: string, Bluesky instance base URL, e.g.
'https://mastodon.social'. May also be provided in the 'instance'
request as a URL query parameter or POST body.
Raises: ValueError if instance isn't a Bluesky instance.
"""
# normalize instance to URL
if not instance:
instance = request.values['instance']
instance = instance.strip().split('@')[-1] # handle addresses, eg user@host.com
parsed = urlparse(instance)
if not parsed.scheme:
instance = 'https://' + instance

# fetch instance info from this instance's API (mostly to test that it's
# actually a Bluesky instance)
try:
resp = util.requests_get(urljoin(instance, INSTANCE_API))
resp.raise_for_status()
except requests.RequestException:
logger.info('Error', exc_info=True)
resp = None

is_json = resp and resp.headers.get('Content-Type', '').strip().startswith(
'application/json')
if is_json:
logger.info(resp.text)
if (not resp or not resp.ok or not is_json or
not self._version_ok(resp.json().get('version'))):
msg = f"{instance} doesn't look like a {self.LABEL} instance."
logger.info(resp)
logger.info(msg)
raise ValueError(msg)

# if we got redirected, update instance URL
parsed = list(urlparse(resp.url))
parsed[2] = '/' # path
instance = urlunparse(parsed)

app_name = self.app_name()
app_url = self.app_url()
query = self.APP_CLASS.query(self.APP_CLASS.instance == instance,
self.APP_CLASS.app_url == app_url)
if appengine_info.DEBUG:
# disambiguate different apps in dev_appserver, since their app_url will
# always be localhost
query = query.filter(self.APP_CLASS.app_name == app_name)
app = query.get()

if app and self.EXPIRE_APPS_BEFORE and app.created_at < self.EXPIRE_APPS_BEFORE:
logging.info(f'Creating new client app for {instance} because existing app {app.key} was created before EXPIRE_APPS_BEFORE {self.EXPIRE_APPS_BEFORE}')
app = None

if not app:
app = self._register_app(instance, app_name, app_url)
app.instance_info = resp.text
app.put()

logger.info(f'Starting OAuth for {self.LABEL} instance {instance}')
app_data = json_loads(app.data)
return urljoin(instance, AUTH_CODE_API % {
'client_id': app_data['client_id'],
'client_secret': app_data['client_secret'],
'redirect_uri': quote_plus(self.to_url()),
'state': _store_state(app, state),
'scope': self.scope,
})

def _register_app(self, instance, app_name, app_url):
"""Register a Bluesky API app on a specific instance.
https://docs.joinmastodon.org/methods/apps/
Args:
instance: string
app_name: string
app_url: string
Returns: APP_CLASS
"""
logger.info(f"first time we've seen {self.LABEL} instance {instance} with app {app_name} {app_url}! registering an API app.")

redirect_uris = {urljoin(request.host_url, path)
for path in set(self.REDIRECT_PATHS)}
redirect_uris.add(self.to_url())

resp = util.requests_post(
urljoin(instance, REGISTER_APP_API),
data=urlencode({
'client_name': app_name,
# Bluesky uses Doorkeeper for OAuth, which allows registering
# multiple redirect URIs, separated by newlines.
# https://github.com/doorkeeper-gem/doorkeeper/pull/298
# https://docs.joinmastodon.org/methods/apps/
'redirect_uris': '\n'.join(redirect_uris),
'website': app_url,
# https://docs.joinmastodon.org/api/oauth-scopes/
'scopes': self.SCOPE_SEPARATOR.join(ALL_SCOPES),
}),
# Pixelfed requires this
headers={'Content-Type': 'application/x-www-form-urlencoded'})
resp.raise_for_status()

app_data = json_loads(resp.text)
logger.info(f'Got {app_data}')
app = self.APP_CLASS(instance=instance, app_name=app_name,
app_url=app_url, data=json_dumps(app_data))
return app

@classmethod
def button_html(cls, *args, **kwargs):
kwargs['form_extra'] = kwargs.get('form_extra', '') + f"""
<input type="url" name="instance" class="form-control" placeholder="{cls.LABEL} instance" scheme="https" required style="width: 135px; height: 50px; display:inline;" />"""
return super(Start, cls).button_html(
*args, input_style='background-color: #EBEBEB; padding: 5px', **kwargs)


class Callback(views.Callback):
"""The OAuth callback. Fetches an access token and stores it."""
AUTH_CLASS = BlueskyAuth

def dispatch_request(self):
# handle errors
error = request.values.get('error')
desc = request.values.get('error_description')
if error:
# user_cancelled_login and user_cancelled_authorize are non-standard.
# https://tools.ietf.org/html/rfc6749#section-4.1.2.1
if error in ('user_cancelled_login', 'user_cancelled_authorize', 'access_denied'):
logger.info(f"User declined: {request.values.get('error_description')}")
state = request.values.get('state')
if state:
_, state = _get_state(state)
return self.finish(None, state=state)
else:
flask_util.error(f'{error} {desc}')

app_key, state = _get_state(request.values['state'])
app = ndb.Key(urlsafe=app_key).get()
assert app
app_data = json_loads(app.data)

# extract auth code and request access token
auth_code = request.values['code']
data = {
'grant_type': 'authorization_code',
'code': auth_code,
'client_id': app_data['client_id'],
'client_secret': app_data['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': request.base_url,
}
resp = util.requests_post(
urljoin(app.instance, ACCESS_TOKEN_API), data=urlencode(data),
# Pixelfed requires this
headers={'Content-Type': 'application/x-www-form-urlencoded'})
resp.raise_for_status()
resp_json = resp.json()
logger.debug(f'Access token response: {resp_json}')
if resp_json.get('error'):
flask_util.error(resp_json)

access_token = resp_json['access_token']
user = self.AUTH_CLASS(app=app.key, access_token_str=access_token).get(VERIFY_API).json()
logger.debug(f'User: {user}')
address = f"@{user['username']}@{urlparse(app.instance).netloc}"
auth = self.AUTH_CLASS(id=address, app=app.key, access_token_str=access_token,
user_json=json_dumps(user))
auth.put()

return self.finish(auth, state=state)
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
'google-cloud-ndb>=1.10.1',
'humanize>=3.1.0',
'jinja2>=2.10',
'lexrpc>=0.2',
'mf2py>=1.1',
'mf2util>=0.5.0',
'oauthlib>=3.1',
Expand Down

0 comments on commit 3757f02

Please sign in to comment.