summaryrefslogtreecommitdiff
path: root/yarns
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-08-04 11:33:39 +0300
committerLars Wirzenius <liw@liw.fi>2018-08-05 12:10:42 +0300
commit5416c57cd286ab614129a398fe4d2da681ecc8f4 (patch)
tree13c3c7a7c8fa827286cd7abc77e198122f0794d0 /yarns
parent99ee63f8247a7c89ca1180838db1a4974812ed23 (diff)
downloadqvisqve-5416c57cd286ab614129a398fe4d2da681ecc8f4.tar.gz
Add: OIDC authorization code flow
Diffstat (limited to 'yarns')
-rw-r--r--yarns/300-end-user-auth.yarn153
-rw-r--r--yarns/900-implements.yarn85
-rw-r--r--yarns/900-local.yarn8
-rw-r--r--yarns/lib.py5
4 files changed, 234 insertions, 17 deletions
diff --git a/yarns/300-end-user-auth.yarn b/yarns/300-end-user-auth.yarn
index 46d6236..e69ccad 100644
--- a/yarns/300-end-user-auth.yarn
+++ b/yarns/300-end-user-auth.yarn
@@ -7,39 +7,178 @@ subset of that. It's just enough for us to have some form of login, to
set up a continuous delivery pipeline for it, and to start building
the full thing.
-FIXME: Explain the login process here, with sequence diagram.
+OpenID Connect
+-----------------------------------------------------------------------------
+
+[OpenID Connect]: https://openid.net/specs/openid-connect-core-1_0.html
+[OAuth2]: https://tools.ietf.org/html/rfc6749
+
+[OpenID Connect][] (OIDC) is a standard protocol for authenticating
+end-users. It is an extension of the [OAuth2][] protocol, which
+provides authorization. The distinction is important: OAuth2 allows a
+user let an application access the user's data on some service, but
+does not itself provide a way to verify the user's identity. OIDC
+provides a way for the service to do the identity verification
+(authentication).
+
+There are, in the OIDC context, several entities involved:
+
+* The **end-user**, who owns some data on the resource server. Ownership
+ here is in the legal sense.
+
+* The end-user's user agent, also known as the browser. This is what
+ the end-user interacts with directly.
+
+* The **resource server**, where the actual data is. It is assumed the
+ data is sensitive, and needs to be protected, but that some access
+ should be allowed.
+
+* The **facade application**, which accesses data on the resource
+ server, and processes it in some way for the user's benefit.
+
+* The **OpenID provider** authenticates the user and produces tokens
+ for the facade application to access the user's data on the resource
+ server. This is Qvisqve.
+
+The OIDC authorization code flow works like this, at a very high
+level:
+
+* User initiates an authentication process. Typically by clicking on a
+ login link, but might also happen when the facade notices
+ authentication is needed, such as when its token expires. In either
+ case, there is an HTTP request from the browser to the facade.
+
+* The facade returns a 302 redirect to the browser. The redirect takes
+ the browser to the OpenID provider, and supplies a number of
+ parameters in the URL to identify the application, and what access
+ it wants.
+
+* The OpenID provider verifies the user's identify, such as by asking
+ them for their username and password.
+
+* Once the identity is verified, the OpenID provider redirects the
+ browser back to the facade, and gives a unique, single-use
+ authorization code as part of the redirected URL.
+
+* The facade extracs the authorization code, and requests an access
+ token from the OpenID provider. As part of that, the facade
+ authenticates itself to the OpenID provider.
+
+* The facade uses the access token to get data from the resource
+ server, and shows it to the user via the browser.
+
+Note that the access token never reaches the browser. Also, it is
+given only to an authenticated facade application. Further, the facade
+application never sees the user's login creentials. There's more
+details in the protocol to mitigate replay attack, cross-site
+forgeries and other shenanigans.
+
+Test scenario
+-----------------------------------------------------------------------------
+
+This scenario shows the steps of authenticating the end-user.
SCENARIO end-user interactive login
+We need to have a Qvisqve, and there needs to be a user account
+configured for it. Further, the facade application must also be
+registerd, before the login process starts.
+
GIVEN a Qvisqve configuration for "https://qvisqve"
AND Qvisqve configuration has user account tomjon with password hunter2
AND Qvisqve configuration has application facade
... with callback url https://facade/callback
+ ... and secret happydays
+ ... and allowed scopes read write
AND a running Qvisqve instance
-User goes to the login URL and gets a login page.
+User goes to the facade's login URL and gets a redirect to Qvisqve's
+/auth endpoint. We skip the request to the facade. The redirect to
+/auth includes important parameters. The parameters are:
+
+* `response_type=code` &mdash; identify this as the start of an
+ authorization code flow
+* `scope=openid+read` &mdash; space delimited list of scope names
+ required by the facade, plus `openid` to indicate we want OIDC
+* `client_id=facade` &mdash; identify the application; note that at
+ this point Qvisqve can't verify this
+* `state=RANDOM`&mdash; a unique, single-use, hard-to-guess value
+* `redirect_uri=https://facade/callback` &mdash; where the browser
+ should go when the user has been identified
- WHEN browser requests GET /login
+Since the request to Qvisqve is always done as a result of an HTTP 302
+redirect response from the facade, it's always a GET request, and the
+parameters are part of the URL. The response should be login form. The
+form will have a hidden field, with which Qvisqve will know which
+authentication attempt is happening. This field will have a new,
+unique, hard-to-guess value every time the user authenication starts
+anew. Note that this should probably be different from the `state`
+value from the facade (FIXME: why?).
+
+ WHEN browser requests GET /auth?response_type=code&scope=openid+read&client_id=facade&state=RANDOM&redirect_uri=https://facade/callback
THEN HTTP status code is 200 OK
AND Content-Type is text/html
AND body has an HTML form with field username
AND body has an HTML form with field password
+ AND body has an HTML form with field attempt_id
+ AND HTML form field attempt_id is saved as ATTEMPTID
+
+The user enters their login credentials, and presses submit. Qvisqve
+verifies the credentials.
WHEN browser requests POST /auth, with form values
- ... username=tomjon and password=wrong
+ ... username=tomjon and password=wrong and attempt_id=${ATTEMPTID}
THEN HTTP status code is 401 Unauthorized
+FIXME: a 401 error is unfriendly, the user should be given an
+opportunity to try again. Fix later. Anyway, let's say user can use
+the back button and tries again with the correct password.
+
+Qvisqve accepts the credentials this time, and generates an
+"authorization code". This is a unique, hard-to-predict value that can
+be used only once. The code is added to the facade callback URL, and
+the browser is redirected to that. This is how the facade gets the
+code. The browser also gets the code, but since using the code
+requires the facade's client credentials, the browser can't use the
+authorization code for anything, so it's acceptably safe to let the
+browser see it.
+
WHEN browser requests POST /auth, with form values
- ... username=tomjon and password=hunter2
+ ... username=tomjon and password=hunter2 and attempt_id=${ATTEMPTID}
THEN HTTP status code is 302 Found
- AND HTTP Location header is https://facade/callback?code=123
+ AND HTTP Location header starts with https://facade/callback?
+ AND HTTP Location header is saved as LOCATION
+ AND authorization code from LOCATION is saved as CODE
+
+The browser follows the redirect to the facade. The facade extracts
+the authorization code, and uses its own client credentials to
+retrieve the access token corresponding to the code.
+
+ WHEN facade requests POST /token, with
+ ... form values grant_type=authorization_code and code=${CODE}
+ ... using Basic Auth with username facade, password wrong
+ THEN HTTP status code is 401 Unauthorized
WHEN facade requests POST /token, with
- ... form values grant_type=authorization_code and code=123
+ ... form values grant_type=authorization_code and code=${CODE}
+ ... using Basic Auth with username facade, password happydays
+
+Qvisqve returns the access token. It has the requested scope, minus
+"openid".
+
+FIXME: The aud field should have the facade as one of the values, but
+the resource server needs to also be there. Or else the resource
+server needs to ignore aud, which seems fishy, or it needs to accept
+the facade as the aud, which seems tricky. I don't know how to handle
+this. Needs research and thinking.
+
THEN HTTP status code is 200 OK
AND Content-Type is application/json
AND JSON body has field access_token
AND JSON body has field token_type, with value Bearer
AND JSON body has field expires_in
+ AND access token has a scope field set to read
+ AND access token has a sub field set to tomjon
+
FINALLY Qvisqve is stopped
diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn
index 25a7e11..d63472c 100644
--- a/yarns/900-implements.yarn
+++ b/yarns/900-implements.yarn
@@ -86,21 +86,40 @@ This chapter shows the scenario step implementations.
path = get_next_match()
V['status_code'], V['headers'], V['body'] = get(V['API_URL'] + path, {})
- IMPLEMENTS WHEN (browser|facade) requests POST (\S+), with form values (\S+)=(\S+) and (\S+)=(\S+)
- who = get_next_match()
+ IMPLEMENTS WHEN browser requests POST (\S+), with form values (\S+)=(\S+) and (\S+)=(\S+) and (\S+)=(\S+)
path = get_next_match()
field1 = get_next_match()
- value1 = get_next_match()
+ value1 = expand_vars(get_next_match(), V)
field2 = get_next_match()
- value2 = get_next_match()
+ value2 = expand_vars(get_next_match(), V)
+ field3 = get_next_match()
+ value3 = expand_vars(get_next_match(), V)
headers = {}
body = {
field1: value1,
field2: value2,
+ field3: value3,
}
V['status_code'], V['headers'], V['body'] = post(
V['API_URL'] + path, headers=headers, body=body)
+ IMPLEMENTS WHEN facade requests POST (\S+), with form values (\S+)=(\S+) and (\S+)=(\S+) using Basic Auth with username (\S+), password (\S+)
+ path = get_next_match()
+ field1 = get_next_match()
+ value1 = expand_vars(get_next_match(), V)
+ field2 = get_next_match()
+ value2 = expand_vars(get_next_match(), V)
+ username = get_next_match()
+ password = get_next_match()
+ headers = {}
+ body = {
+ field1: value1,
+ field2: value2,
+ }
+ V['status_code'], V['headers'], V['body'] = post(
+ V['API_URL'] + path, headers=headers, body=body,
+ auth=(username, password))
+
## API access token creation
IMPLEMENTS WHEN client gets an authorization token with scope "(.+)"
@@ -122,10 +141,24 @@ This chapter shows the scenario step implementations.
expected = int(get_next_match())
assertEqual(V['status_code'], expected)
- IMPLEMENTS THEN HTTP (\S+) header is (.+)
+ IMPLEMENTS THEN HTTP (\S+) header starts with (.+)
+ header = get_next_match()
+ wanted = expand_vars(get_next_match(), V)
+ actual = V['headers'].get(header)
+ assertTrue(actual.startswith(wanted))
+
+ IMPLEMENTS THEN HTTP (\S+) header is saved as (.+)
header = get_next_match()
- value = expand_vars(get_next_match(), V)
- assertEqual(V['headers'].get(header), value)
+ name = get_next_match()
+ V[name] = V['headers'].get(header)
+
+ IMPLEMENTS THEN authorization code from (\S+) is saved as (\S+)
+ import urlparse
+ var1 = get_next_match()
+ var2 = get_next_match()
+ parts = urlparse.urlparse(V[var1])
+ params = urlparse.parse_qs(parts.query)
+ V[var2] = params['code'][0]
IMPLEMENTS THEN remember HTTP (\S+) header as (.+)
header = get_next_match()
@@ -178,6 +211,19 @@ This chapter shows the scenario step implementations.
pattern = '<input name="{}"'.format(field)
assertTrue(pattern in body)
+ IMPLEMENTS THEN HTML form field (.+) is saved as (\S+)
+ import re
+ field = get_next_match()
+ name = get_next_match()
+ body = V['body']
+ pattern = '<input name="{}" value="([^"]+)"'.format(field)
+ m = re.search(pattern, body, re.M)
+ print('body', repr(body))
+ print('pattern:', pattern)
+ print('m:', m)
+ print('m.groups():', m.groups())
+ V[name] = m.groups(0)[0]
+
IMPLEMENTS THEN Content-Type is (\S+)
wanted = get_next_match()
headers = V['headers']
@@ -222,3 +268,28 @@ This chapter shows the scenario step implementations.
body = V['body']
body = json.loads(body)
assertEqual(body.get(field), value)
+
+ IMPLEMENTS THEN access token has a (\S+) field set to (\S+)
+ field = get_next_match()
+ value = get_next_match()
+ body = V['body']
+ body = json.loads(body)
+ token = body['access_token']
+ claims = token_decode(token, V['pubkey'])
+ print('claims', claims)
+ print('value', value)
+ assertEqual(claims.get(field), value)
+
+ IMPLEMENTS THEN access token has an (\S+) field that is not empty
+ field = get_next_match()
+ value = get_next_match()
+ body = V['body']
+ body = json.loads(body)
+ token = body['access_token']
+ claims = token_decode(token)
+ tf = token.get(field)
+ assertTrue(tf is not None)
+ assertTrue(isinstance(tf, str))
+ assertTrue(tf != "")
+
+
diff --git a/yarns/900-local.yarn b/yarns/900-local.yarn
index 8c9fd1d..c9721bc 100644
--- a/yarns/900-local.yarn
+++ b/yarns/900-local.yarn
@@ -42,10 +42,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
password = get_next_match()
V['users'] = { username: password }
- IMPLEMENTS GIVEN Qvisqve configuration has application (\S+) with callback url (\S+)
+ IMPLEMENTS GIVEN Qvisqve configuration has application (\S+) with callback url (\S+) and secret (\S+) and allowed scopes (.+)
app = get_next_match()
callback = get_next_match()
+ secret = get_next_match()
+ scopestr = get_next_match()
+ # FIXME: store secret somewhere
V['applications'] = { app: callback }
+ V['client_id'] = app
+ V['client_secret'] = secret
+ V['allowed_scopes'] = scopestr.split()
## Authentication setup
diff --git a/yarns/lib.py b/yarns/lib.py
index 7d83c08..96c93ad 100644
--- a/yarns/lib.py
+++ b/yarns/lib.py
@@ -74,9 +74,10 @@ def get(url, headers=None):
return r.status_code, dict(r.headers), r.content
-def post(url, headers=None, body=None):
+def post(url, headers=None, body=None, auth=None):
r = requests.post(
- url, headers=headers, data=body, verify=False, allow_redirects=False)
+ url, headers=headers, data=body, auth=auth, verify=False,
+ allow_redirects=False)
return r.status_code, dict(r.headers), r.text