improve API, add documentation

This commit is contained in:
Kyle Mahan 2015-01-19 09:01:56 -08:00
parent b958da9bd1
commit d039e29f30
3 changed files with 176 additions and 54 deletions

View File

@ -1,2 +1,28 @@
# flask-micropub # Flask-Micropub
Flask extension to support IndieAuth and Micropub clients.
A Flask extension to support IndieAuth and Micropub clients.
```python
from flask import Flask, request, url_for
from flask.ext.micropub import Micropub
app = Flask(__name__)
micropub = Micropub(app)
@app.route('/login')
def login():
return micropub.authorize(
me, redirect_url=url_for('micropub_callback', _external=True),
scope=request.args.get('scope'))
@app.route('/micropub-callback')
@micropub.authorized_handler
def micropub_callback(resp):
print('success!', resp.me, resp.access_token, resp.next_url, resp.error)
```
See details at https://indiewebcamp.com/IndieAuth and https://indiewebcamp.com/Micropub

View File

@ -1,15 +1,15 @@
from flask import Flask, request, url_for from flask import Flask, request, url_for
from flask.ext.micropub import Micropub from flask.ext.micropub import Micropub
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = 'my super secret key'
micropub = Micropub(app) micropub = Micropub(app)
@app.route('/micropub-callback') @app.route('/micropub-callback')
@micropub.authorized_handler @micropub.authorized_handler
def micropub_callback(me, token, next_url, error): def micropub_callback(resp):
return """ return """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -22,7 +22,7 @@ def micropub_callback(me, token, next_url, error):
</ul> </ul>
</body> </body>
</html> </html>
""".format(me, token, next_url, error) """.format(resp.me, resp.access_token, resp.next_url, resp.error)
@app.route('/') @app.route('/')
@ -30,13 +30,19 @@ def index():
me = request.args.get('me') me = request.args.get('me')
if me: if me:
return micropub.authorize( return micropub.authorize(
me, url_for('micropub_callback', _external=True)) me, redirect_url=url_for('micropub_callback', _external=True),
scope=request.args.get('scope'))
return """ return """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<body> <body>
<form action="" method="GET"> <form action="" method="GET">
<input type="text" name="me" placeholder="your domain.com"/> <input type="text" name="me" placeholder="your domain.com"/>
<select name="scope">
<option>read</option>
<option>write</option>
<option>comment</option>
</select>
<button type="submit">Authorize</button> <button type="submit">Authorize</button>
</form> </form>
</body> </body>

View File

@ -1,4 +1,9 @@
"""This extension adds the ability to login to a Flask-based website # -*- coding: utf-8 -*-
"""
Flask-Micropub
==============
This extension adds the ability to login to a Flask-based website
using [IndieAuth](https://indiewebcamp.com/IndieAuth), and to request using [IndieAuth](https://indiewebcamp.com/IndieAuth), and to request
an [Micropub](https://indiewebcamp.com/Micropub) access token. an [Micropub](https://indiewebcamp.com/Micropub) access token.
""" """
@ -9,61 +14,114 @@ import flask
import functools import functools
import sys import sys
if sys.version > '3': if sys.version < '3':
from urllib.parse import urlencode, parse_qs
else:
from urlparse import parse_qs from urlparse import parse_qs
from urllib import urlencode from urllib import urlencode
else:
from urllib.parse import urlencode, parse_qs
class Micropub: class Micropub:
def __init__(self, app): """Flask-Micropub provides support for IndieAuth/Micropub
authentication and authorization.
Args:
app (flask.Flask, optional): the flask application to extend.
client_id (string, optional): the IndieAuth client id, will be displayed
when the user is asked to authorize this client.
"""
def __init__(self, app=None, client_id=None):
self.app = app self.app = app
if app is not None: if app is not None:
self.init_app(app) self.init_app(app, client_id)
def init_app(self, app): def init_app(self, app, client_id=None):
"""Initialize the Micropub extension if it was not given app
in the constructor.
Args:
app (flask.Flask): the flask application to extend.
client_id (string, optional): the IndieAuth client id, will be
displayed when the user is asked to authorize this client. If not
provided, the app name will be used.
"""
if client_id:
self.client_id = client_id
else:
self.client_id = app.name self.client_id = app.name
def authorize(self, me, redirect_uri, next=None, scope='read'): def authorize(self, me, redirect_url, next_url=None, scope='read'):
"""Authorize a user via Micropub.
Args:
me (string): the authing user's URL. if it does not begin with
https?://, http:// will be prepended.
redirect_url (string): the URL that IndieAuth should redirect to
when it's finished; This should be the authorized_handler
next_url (string, optional): passed through the whole auth process,
useful if you want to redirect back to a starting page when auth
is complete.
scope (string, optional): a space-separated string of micropub
scopes. 'read' by default.
Returns:
a redirect to the user's specified authorization url, or
https://indieauth.com/auth if none is provided.
"""
if not me.startswith('http://') and not me.startswith('https://'): if not me.startswith('http://') and not me.startswith('https://'):
me = 'http://' + me me = 'http://' + me
auth_url, token_url, micropub_url = self._discover_endpoints(me) auth_url, token_url, micropub_url = self._discover_endpoints(me)
if not auth_url: if not auth_url:
auth_url = 'https://indieauth.com/auth' auth_url = 'https://indieauth.com/auth'
# save the endpoints so we don't have to scrape the target page again
# right awway
try:
flask.session['_micropub_endpoints'] = (auth_url, token_url,
micropub_url)
except RuntimeError:
pass # we'll look it up again later
auth_params = { auth_params = {
'me': me, 'me': me,
'client_id': self.client_id, 'client_id': self.client_id,
'redirect_uri': redirect_uri, 'redirect_uri': redirect_url,
'scope': scope, 'scope': scope,
} }
if next: if next_url:
auth_params['state'] = next auth_params['state'] = next_url
return flask.redirect( return flask.redirect(
auth_url + '?' + urlencode(auth_params)) auth_url + '?' + urlencode(auth_params))
def authorized_handler(self, f): def authorized_handler(self, f):
"""Decorates the authorization callback endpoint. The endpoint should
take one argument, a flask.ext.micropub.AuthResponse.
"""
@functools.wraps(f) @functools.wraps(f)
def decorated(*args, **kwargs): def decorated():
data = self._handle_response() resp = self._handle_response()
return f(*(data + args), **kwargs) return f(resp)
return decorated return decorated
def _handle_response(self): def _handle_response(self):
redirect_uri = flask.url_for(flask.request.endpoint, _external=True) redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
confirmed_me = None
access_token = None access_token = None
state = flask.request.args.get('state') state = flask.request.args.get('state')
next_url = state next_url = state
if '_micropub_endpoints' in flask.session:
auth_url, token_url, micropub_url \
= flask.session['_micropub_endpoints']
del flask.session['_micropub_endpoints']
else:
auth_url, token_url, micropub_url = self._discover_endpoints( auth_url, token_url, micropub_url = self._discover_endpoints(
flask.request.args.get('me')) flask.request.args.get('me'))
if not auth_url: if not auth_url:
return (confirmed_me, access_token, next_url, return AuthResponse(
'no authorization endpoint') next_url=next_url, error='no authorization endpoint')
code = flask.request.args.get('code') code = flask.request.args.get('code')
client_id = '' client_id = ''
@ -78,16 +136,24 @@ class Micropub:
rdata = parse_qs(response.text) rdata = parse_qs(response.text)
if response.status_code != 200: if response.status_code != 200:
return (confirmed_me, access_token, next_url, return AuthResponse(
'authorization failed. {}: {}'.format( next_url=next_url,
error='authorization failed. {}: {}'.format(
rdata.get('error'), rdata.get('error_description'))) rdata.get('error'), rdata.get('error_description')))
if 'me' not in rdata: if 'me' not in rdata:
return (confirmed_me, access_token, next_url, return AuthResponse(
'missing "me" in response') next_url=next_url,
error='missing "me" in response')
confirmed_me = rdata.get('me')[0] confirmed_me = rdata.get('me')[0]
if not token_url or not micropub_url:
# successfully auth'ed user, no micropub endpoint
return AuthResponse(
me=confirmed_me, next_url=next_url,
error='no micropub endpoint found.')
# request an access token # request an access token
token_response = requests.post(token_url, data={ token_response = requests.post(token_url, data={
'code': code, 'code': code,
@ -98,18 +164,22 @@ class Micropub:
}) })
if token_response.status_code != 200: if token_response.status_code != 200:
return (confirmed_me, access_token, next_url, return AuthResponse(
'bad response from token endpoint: {}' me=confirmed_me, next_url=next_url,
error='bad response from token endpoint: {}'
.format(token_response)) .format(token_response))
tdata = parse_qs(token_response.text) tdata = parse_qs(token_response.text)
if 'access_token' not in tdata: if 'access_token' not in tdata:
return (confirmed_me, access_token, next_url, return AuthResponse(
'response from token endpoint missing access_token: {}' me=confirmed_me, next_url=next_url,
error='response from token endpoint missing access_token: {}'
.format(tdata)) .format(tdata))
# success!
access_token = tdata.get('access_token')[0] access_token = tdata.get('access_token')[0]
return confirmed_me, access_token, next_url, None return AuthResponse(me=confirmed_me, access_token=access_token,
next_url=next_url)
def _discover_endpoints(self, me): def _discover_endpoints(self, me):
me_response = requests.get(me) me_response = requests.get(me)
@ -124,3 +194,23 @@ class Micropub:
return (auth_endpoint and auth_endpoint['href'], return (auth_endpoint and auth_endpoint['href'],
token_endpoint and token_endpoint['href'], token_endpoint and token_endpoint['href'],
micropub_endpoint and micropub_endpoint['href']) micropub_endpoint and micropub_endpoint['href'])
class AuthResponse:
"""Authorization response, passed to the authorized_handler endpoint.
Attributes:
me (string): The authenticated user's URL. This will be non-None if and
only if the user was successfully authenticated.
access_token (string): The authorized user's micropub access token.
next_url (string): The optional URL that was passed to authorize.
error (string): describes the error encountered if any. It is possible
that the authentication step will succeed but the access token step
will fail, in which case me will be non-None, and error will describe
this condition.
"""
def __init__(self, me=None, access_token=None, next_url=None, error=None):
self.me = me
self.access_token = access_token
self.next_url = next_url
self.error = error