diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d79b2df
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Change Log
+All notable changes to this project will be documented in this file.
+
+## 0.2.0 - 2015-02-07
+### Changed
+- Started keeping a changelog!
+- Added a separate 'authenticate' flow to provide explicit support for
+ calling out to indieauth without requesting any sort of access
+ token.
+- Redirect_url is now determined automatically based on the
+ authenticated_handler or authorized_handler annotations
diff --git a/README.md b/README.md
index 54a2fe0..096b2b5 100644
--- a/README.md
+++ b/README.md
@@ -2,20 +2,48 @@
A Flask extension to support IndieAuth and Micropub clients.
+## Authentication
+
+Authentication uses the
+[IndieAuth](https://indiewebcamp.com/IndieAuth) flow to confirm a user
+controls a particular URL, without requesting any sort of permissions
+or access token. Annotate an endpoint with
+`@micropub.authenticated_handler` and then call
+`micropub.authenticate` to initiate the login.
+
+## Authorization
+
+Authorization uses the full
+[Micropub](https://indiewebcamp.com/Micropub) flow to authenticate a
+user and then request an access token with which to make micropub
+requests. Annotate an endpoint with `@micropub.authorized_handler` and
+then call `micropub.authorize` to initiate the login.
+
+## CSRF
+
+MicropubClient provides a simple mechanism to deter Cross-Site Request
+Forgery. Based on
+[this Flask snippet](http://flask.pocoo.org/snippets/3/), we generate
+a random string, pass it to the indieauth service via the state
+parameter, and then confirm we get the same random string back later.
+
+This helps prevent malicious sites from sending users to your
+indieauth endpoint against their will.
+
+## Example
```python
from flask import Flask, request, url_for
-from flask.ext.micropub import Micropub
+from flask.ext.micropub import MicropubClient
app = Flask(__name__)
-micropub = Micropub(app)
+micropub = MicropubClient(app)
@app.route('/login')
def login():
return micropub.authorize(
- me, redirect_url=url_for('micropub_callback', _external=True),
- scope=request.args.get('scope'))
+ me, scope=request.args.get('scope'))
@app.route('/micropub-callback')
@@ -25,4 +53,6 @@ def micropub_callback(resp):
```
-See details at https://indiewebcamp.com/IndieAuth and https://indiewebcamp.com/Micropub
+See example.py for a more thorough example. Protocol details at
+https://indiewebcamp.com/IndieAuth and
+https://indiewebcamp.com/Micropub
diff --git a/example.py b/example.py
index b137c91..f87ad6c 100644
--- a/example.py
+++ b/example.py
@@ -7,6 +7,61 @@ app.config['SECRET_KEY'] = 'my super secret key'
micropub = MicropubClient(app)
+@app.route('/')
+def index():
+ return """
+
+
+
+
+
+
+
+ """
+
+
+@app.route('/authenticate')
+def authenticate():
+ return micropub.authenticate(
+ request.args.get('me'), next_url=url_for('index'))
+
+
+@app.route('/authorize')
+def authorize():
+ return micropub.authorize(
+ request.args.get('me'), next_url=url_for('index'),
+ scope=request.args.get('scope'))
+
+
+@app.route('/indieauth-callback')
+@micropub.authenticated_handler
+def indieauth_callback(resp):
+ return """
+
+
+
+ Authenticated:
+
+ - me: {}
+ - next: {}
+ - error: {}
+
+
+
+ """.format(resp.me, resp.next_url, resp.error)
+
+
@app.route('/micropub-callback')
@micropub.authorized_handler
def micropub_callback(resp):
@@ -14,6 +69,7 @@ def micropub_callback(resp):
+ Authorized:
- me: {}
- endpoint: {}
@@ -27,30 +83,5 @@ def micropub_callback(resp):
resp.next_url, resp.error)
-@app.route('/')
-def index():
- me = request.args.get('me')
- if me:
- return micropub.authorize(
- me, redirect_url=url_for('micropub_callback', _external=True),
- next_url=url_for('index'), scope=request.args.get('scope'))
- return """
-
-
-
-
-
-
- """
-
-
if __name__ == '__main__':
app.run(debug=True)
diff --git a/flask_micropub.py b/flask_micropub.py
index 10f53ec..711dba9 100644
--- a/flask_micropub.py
+++ b/flask_micropub.py
@@ -53,14 +53,31 @@ class MicropubClient:
else:
self.client_id = app.name
- def authorize(self, me, redirect_url, next_url=None, scope='read'):
+ def authenticate(self, me, next_url=None):
+ """Authenticate a user via IndieAuth.
+
+ Args:
+ me (string): the authing user's URL. if it does not begin with
+ https?://, http:// will be prepended.
+ 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.
+ scopes. 'read' by default.
+
+ Returns:
+ a redirect to the user's specified authorization url, or
+ https://indieauth.com/auth if none is provided.
+ """
+ redirect_url = flask.url_for(
+ self._authenticated_handler.func_name, _external=True)
+ return self._start_indieauth(me, redirect_url, next_url, None)
+
+ def authorize(self, me, 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.
@@ -68,9 +85,32 @@ class MicropubClient:
scopes. 'read' by default.
Returns:
- a redirect to the user's specified authorization url, or
+ a redirect to the user's specified authorization
https://indieauth.com/auth if none is provided.
"""
+ redirect_url = flask.url_for(
+ self._authorized_handler.func_name, _external=True)
+ return self._start_indieauth(me, redirect_url, next_url, scope)
+
+ def _start_indieauth(self, me, redirect_url, next_url, scope):
+ """Helper for both authentication and authorization. Kicks off
+ IndieAuth by fetching the authorization endpoint from the user's
+ homepage and redirecting to it.
+
+ Args:
+ me (string): the authing user's URL. if it does not begin with
+ https?://, http:// will be prepended.
+ redirect_url: the callback URL that we pass to the auth endpoint.
+ 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): a space-separated string of micropub scopes.
+
+ Returns:
+ a redirect to the user's specified authorization
+ 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)
@@ -84,31 +124,47 @@ class MicropubClient:
flask.session['_micropub_endpoints'] = (
auth_url, token_url, micropub_url)
- auth_url = auth_url + '?' + urlencode({
+ auth_params = {
'me': me,
'client_id': self.client_id,
'redirect_uri': redirect_url,
- 'scope': scope,
'state': '{}|{}'.format(csrf_token, next_url or ''),
- })
+ }
+ if scope:
+ auth_params['scope'] = scope
+
+ auth_url = auth_url + '?' + urlencode(auth_params)
flask.current_app.logger.debug('redirecting to %s', auth_url)
return flask.redirect(auth_url)
+ def authenticated_handler(self, f):
+ """Decorates the authentication callback endpoint. The endpoint should
+ take one argument, a flask.ext.micropub.AuthResponse.
+ """
+ @functools.wraps(f)
+ def decorated():
+ resp = self._handle_authorize_response()
+ return f(resp)
+ self._authenticated_handler = decorated
+ return decorated
+
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():
- resp = self._handle_response()
+ resp = self._handle_authorize_response()
return f(resp)
+ self._authorized_handler = decorated
return decorated
- def _handle_response(self):
- access_token = None
- redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
+ def _handle_authenticate_response(self):
+ code = flask.request.args.get('code')
state = flask.request.args.get('state')
+ redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
+
if state and '|' in state:
csrf_token, next_url = state.split('|', 1)
else:
@@ -123,19 +179,15 @@ class MicropubClient:
next_url=next_url, error='mismatched CSRF token')
if '_micropub_endpoints' in flask.session:
- auth_url, token_url, micropub_url \
- = flask.session['_micropub_endpoints']
- del flask.session['_micropub_endpoints']
+ auth_url = flask.session['_micropub_endpoints'][0]
else:
- auth_url, token_url, micropub_url = self._discover_endpoints(
- flask.request.args.get('me'))
+ auth_url = self._discover_endpoints(
+ flask.request.args.get('me'))[0]
if not auth_url:
return AuthResponse(
next_url=next_url, error='no authorization endpoint')
- code = flask.request.args.get('code')
-
# validate the authorization code
auth_data = {
'code': code,
@@ -167,17 +219,34 @@ class MicropubClient:
error='missing "me" in response')
confirmed_me = rdata.get('me')[0]
+ return AuthResponse(me=confirmed_me, next_url=next_url)
+
+ def _handle_authorize_response(self):
+ authenticate_response = self._handle_authenticate_response()
+ code = flask.request.args.get('code')
+ state = flask.request.args.get('state')
+ redirect_uri = flask.url_for(flask.request.endpoint, _external=True)
+
+ if authenticate_response.error:
+ return authenticate_response
+
+ if '_micropub_endpoints' in flask.session:
+ _, token_url, micropub_url = flask.session['_micropub_endpoints']
+ else:
+ _, token_url, micropub_url = self._discover_endpoints(
+ flask.request.args.get('me'))
if not token_url or not micropub_url:
# successfully auth'ed user, no micropub endpoint
return AuthResponse(
- me=confirmed_me, next_url=next_url,
+ me=authenticate_response.me,
+ next_url=authenticate_response.next_url,
error='no micropub endpoint found.')
# request an access token
token_data = {
'code': code,
- 'me': confirmed_me,
+ 'me': authenticate_response.me,
'redirect_uri': redirect_uri,
'client_id': self.client_id,
'state': state,
@@ -192,21 +261,26 @@ class MicropubClient:
if token_response.status_code != 200:
return AuthResponse(
- me=confirmed_me, next_url=next_url,
+ me=authenticate_response.me,
+ next_url=authenticate_response.next_url,
error='bad response from token endpoint: {}'
.format(token_response))
tdata = parse_qs(token_response.text)
if 'access_token' not in tdata:
return AuthResponse(
- me=confirmed_me, next_url=next_url,
+ me=authenticate_response.me,
+ next_url=authenticate_response.next_url,
error='response from token endpoint missing access_token: {}'
.format(tdata))
# success!
access_token = tdata.get('access_token')[0]
- return AuthResponse(me=confirmed_me, micropub_endpoint=micropub_url,
- access_token=access_token, next_url=next_url)
+ return AuthResponse(
+ me=authenticate_response.me,
+ micropub_endpoint=micropub_url,
+ access_token=access_token,
+ next_url=authenticate_response.next_url)
def _discover_endpoints(self, me):
me_response = requests.get(me)
diff --git a/setup.py b/setup.py
index bada29b..b080121 100644
--- a/setup.py
+++ b/setup.py
@@ -3,15 +3,15 @@ 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.
+using IndieAuth (https://indiewebcamp.com/IndieAuth), and to request
+a Micropub (https://indiewebcamp.com/Micropub) access token.
"""
from setuptools import setup
setup(
name='Flask-Micropub',
- version='0.1.4',
+ version='0.2.0',
url='https://github.com/kylewm/flask-micropub/',
license='BSD',
author='Kyle Mahan',