diff --git a/README.md b/README.md index 739921c..54a2fe0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ -# flask-micropub -Flask extension to support IndieAuth and Micropub clients. +# Flask-Micropub + +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 diff --git a/example.py b/example.py index 071e7ea..7cd2577 100644 --- a/example.py +++ b/example.py @@ -1,28 +1,28 @@ - from flask import Flask, request, url_for from flask.ext.micropub import Micropub app = Flask(__name__) +app.config['SECRET_KEY'] = 'my super secret key' micropub = Micropub(app) @app.route('/micropub-callback') @micropub.authorized_handler -def micropub_callback(me, token, next_url, error): +def micropub_callback(resp): return """ - - - + + + - """.format(me, token, next_url, error) + """.format(resp.me, resp.access_token, resp.next_url, resp.error) @app.route('/') @@ -30,16 +30,22 @@ def index(): me = request.args.get('me') if me: 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 """ - -
- - -
- + +
+ + + +
+ """ diff --git a/flask_micropub.py b/flask_micropub.py index f1b7c7e..66a55fd 100644 --- a/flask_micropub.py +++ b/flask_micropub.py @@ -1,6 +1,11 @@ -"""This extension adds the ability to login to a Flask-based website -using [IndieAuth](https://indiewebcamp.com/IndieAuth), and to request -an [Micropub](https://indiewebcamp.com/Micropub) access token. +# -*- 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 + an [Micropub](https://indiewebcamp.com/Micropub) access token. """ import requests @@ -9,61 +14,114 @@ import flask import functools import sys -if sys.version > '3': - from urllib.parse import urlencode, parse_qs -else: +if sys.version < '3': from urlparse import parse_qs from urllib import urlencode +else: + from urllib.parse import urlencode, parse_qs 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 if app is not None: - self.init_app(app) + self.init_app(app, client_id) - def init_app(self, app): - self.client_id = app.name + def init_app(self, app, client_id=None): + """Initialize the Micropub extension if it was not given app + in the constructor. - def authorize(self, me, redirect_uri, next=None, scope='read'): + 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 + + 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://'): me = 'http://' + me auth_url, token_url, micropub_url = self._discover_endpoints(me) if not auth_url: 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 = { 'me': me, 'client_id': self.client_id, - 'redirect_uri': redirect_uri, + 'redirect_uri': redirect_url, 'scope': scope, } - if next: - auth_params['state'] = next + if next_url: + auth_params['state'] = next_url return flask.redirect( auth_url + '?' + urlencode(auth_params)) def authorized_handler(self, f): + """Decorates the authorization callback endpoint. The endpoint should + take one argument, a flask.ext.micropub.AuthResponse. + """ @functools.wraps(f) - def decorated(*args, **kwargs): - data = self._handle_response() - return f(*(data + args), **kwargs) + def decorated(): + resp = self._handle_response() + return f(resp) return decorated def _handle_response(self): redirect_uri = flask.url_for(flask.request.endpoint, _external=True) - confirmed_me = None access_token = None state = flask.request.args.get('state') next_url = state - auth_url, token_url, micropub_url = self._discover_endpoints( - flask.request.args.get('me')) + + 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( + flask.request.args.get('me')) if not auth_url: - return (confirmed_me, access_token, next_url, - 'no authorization endpoint') + return AuthResponse( + next_url=next_url, error='no authorization endpoint') code = flask.request.args.get('code') client_id = '' @@ -78,16 +136,24 @@ class Micropub: rdata = parse_qs(response.text) if response.status_code != 200: - return (confirmed_me, access_token, next_url, - 'authorization failed. {}: {}'.format( - rdata.get('error'), rdata.get('error_description'))) + return AuthResponse( + next_url=next_url, + error='authorization failed. {}: {}'.format( + rdata.get('error'), rdata.get('error_description'))) if 'me' not in rdata: - return (confirmed_me, access_token, next_url, - 'missing "me" in response') + return AuthResponse( + next_url=next_url, + error='missing "me" in response') 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 token_response = requests.post(token_url, data={ 'code': code, @@ -98,18 +164,22 @@ class Micropub: }) if token_response.status_code != 200: - return (confirmed_me, access_token, next_url, - 'bad response from token endpoint: {}' - .format(token_response)) + return AuthResponse( + me=confirmed_me, next_url=next_url, + error='bad response from token endpoint: {}' + .format(token_response)) tdata = parse_qs(token_response.text) if 'access_token' not in tdata: - return (confirmed_me, access_token, next_url, - 'response from token endpoint missing access_token: {}' - .format(tdata)) + return AuthResponse( + me=confirmed_me, next_url=next_url, + error='response from token endpoint missing access_token: {}' + .format(tdata)) + # success! 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): me_response = requests.get(me) @@ -124,3 +194,23 @@ class Micropub: return (auth_endpoint and auth_endpoint['href'], token_endpoint and token_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