Skip to content

Commit

Permalink
Refactor implementation and API (#30)
Browse files Browse the repository at this point in the history
* Refactor library and public API, preparing for multi-provider support.

* Update example app, and tests for it.

* Rename permanent session config value and change the default to True.

With a permanent session by the default, the user data will be persisted
even after browser restarts.

* Update documentation.
  • Loading branch information
zamzterz authored Sep 16, 2018
1 parent 0b11411 commit a009ae1
Show file tree
Hide file tree
Showing 17 changed files with 1,267 additions and 494 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
.cache/
.coverage
coverage.xml
.pytest_cache
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ script:
- tox

after_success:
- py.test tests/ --cov=./
- py.test tests/ example/ --cov=./
- codecov
147 changes: 80 additions & 67 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
[![codecov.io](https://codecov.io/github/zamzterz/Flask-pyoidc/coverage.svg?branch=master)](https://codecov.io/github/its-dirg/Flask-pyoidc?branch=master)
[![Build Status](https://travis-ci.org/zamzterz/Flask-pyoidc.svg?branch=master)](https://travis-ci.org/zamzterz/Flask-pyoidc)

This Flask extension provides simple OpenID Connect authentication, by using [pyoidc](https://github.com/rohe/pyoidc).
Currently only ["Code Flow"](http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth)) is supported.
This Flask extension provides simple OpenID Connect authentication, backed by [pyoidc](https://github.com/rohe/pyoidc).

## Usage
*Currently only ["Authorization Code Flow"](http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) is supported.*

## Example

Have a look at the [example Flask app](example/app.py) for a full example of how to use this extension.

## Configuration

### Provider and client configuration

Expand All @@ -19,87 +24,112 @@ of the client registration modes.

To use a provider which supports dynamic discovery it suffices to specify the issuer URL:
```python
auth = OIDCAuthentication(issuer='https://op.example.com')
from flask_pyoidc.provider_configuration import ProviderConfiguration
from flask_pyoidc.flask_pyoidc import OIDCAuthentication

auth = OIDCAuthentication(ProviderConfiguration(issuer='https://op.example.com', [client configuration]))
```

#### Static provider configuration

To use a provider not supporting dynamic discovery, the static provider configuration can be specified:
To use a provider not supporting dynamic discovery, the static provider metadata can be specified:
```python
provider_config = {
'issuer': 'https://op.example.com',
'authorization_endpoint': 'https://op.example.com/authorize',
'token_endpoint': 'https://op.example.com/token',
'userinfo_endpoint': 'https://op.example.com/userinfo'
}
auth = OIDCAuthentication(provider_configuration_info=provider_config)
from flask_pyoidc.provider_configuration import ProviderConfiguration, ProviderMetadata
from flask_pyoidc.flask_pyoidc import OIDCAuthentication

provider_metadata = ProviderMetadata(issuer='https://op.example.com',
authorization_endpoint='https://op.example.com/auth',
jwks_uri='https://op.example.com/jwks')
auth = OIDCAuthentication(ProviderConfiguration(provider_metadata=provider_metadata, [client configuration]))
```

See the OpenID Connect specification for more information about the
[provider metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata).


#### Static client registration

If you have already registered a client with the provider all client registration information can be specified:
If you have already registered a client with the provider, specify the client credentials directly:
```python
client_info = {
'client_id': 'cl41ekfb9j',
'client_secret': 'm1C659wLipXfUUR50jlZ',
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
from flask_pyoidc.flask_pyoidc import OIDCAuthentication

}
auth = OIDCAuthentication(client_registration_info=client_info)
client_metadata = ClientMetadata(client_id='cl41ekfb9j', client_secret='m1C659wLipXfUUR50jlZ')
auth = OIDCAuthentication(ProviderConfiguration([provider configuration], client_metadata=client_metadata))
```

**Note: The redirect URIs registered with the provider MUST include `<application_url>/redirect_uri`,
where `<application_url>` is the URL for the Flask application.**
where `<application_url>` is the URL of the Flask application.**

#### Session refresh
#### Dynamic client registration

To dynamically register a new client for your application, the required client registration info can be specified:

If your OpenID Connect provider supports the `prompt=none` parameter, the library can automatically support session refresh on your behalf.
This ensures that the user session attributes (OIDC claims, user being active, etc.) are valid and up-to-date without having to log the user out and back in.
To use the feature simply pass the parameter requesting the session refresh interval as such:
```python
client_info = {
'client_id': 'cl41ekfb9j',
'client_secret': 'm1C659wLipXfUUR50jlZ',
'session_refresh_interval_seconds': 900
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientRegistrationInfo
from flask_pyoidc.flask_pyoidc import OIDCAuthentication

}
auth = OIDCAuthentication(client_registration_info=client_info)
client_registration_info = ClientRegistrationInfo(client_name='Test App', contacts=['[email protected]'])
auth = OIDCAuthentication(ProviderConfiguration([provider configuration], client_registration_info=client_registration_info))
```

**Note: The client will still be logged out at whichever expiration time you set for the Flask session.
### Flask configuration

#### Dynamic client registration
The application using this extension **MUST** set the following
[builtin configuration values of Flask](http://flask.pocoo.org/docs/config/#builtin-configuration-values):

* `SERVER_NAME`: **MUST** be the same as `<flask_url>` if using static client registration.
* `SECRET_KEY`: This extension relies on [Flask sessions](http://flask.pocoo.org/docs/quickstart/#sessions), which
requires `SECRET_KEY`.

If no `client_id` is specified in the `client_registration_info` constructor parameter, the library will try to
dynamically register a client with the specified provider.
You may also configure the way the user sessions created by this extension are handled:

### Protect an endpoint by authentication
* `OIDC_SESSION_PERMANENT`: If set to `True` (which is the default) the user session will live until the ID Token
expiration time. If set to `False` the session will be deleted when the user closes the browser.

### Session refresh

If your provider supports the `prompt=none` authentication request parameter, this extension can automatically refresh
user sessions. This ensures that the user attributes (OIDC claims, user being active, etc.) are kept up-to-date without
having to log the user out and back in. To enable and configure the feature, specify the interval (in seconds) between
refreshes:
```python
from flask_pyoidc.provider_configuration import ProviderConfiguration
from flask_pyoidc.flask_pyoidc import OIDCAuthentication

auth = OIDCAuthentication(ProviderConfiguration(session_refresh_interval_seconds=1800, [provider/client config])
```

**Note: The user will still be logged out when the session expires (as described above).**

## Protect an endpoint by authentication

To add authentication to one of your endpoints use the `oidc_auth` decorator:
```python
import flask
from flask import Flask, jsonify

from flask_pyoidc.user_session import UserSession

app = Flask(__name__)

@app.route('/')
@app.route('/login')
@auth.oidc_auth
def index():
return jsonify(id_token=flask.session['id_token'], access_token=flask.session['access_token'],
userinfo=flask.session['userinfo'])
user_session = UserSession(flask.session)
return jsonify(access_token=user_session.access_token,
id_token=user_session.id_token,
userinfo=user_session.userinfo)
```

This extension will place three things in the session if they are received from the provider:
After a successful login, this extension will place three things in the user session (if they are received from the
provider):
* [ID Token](http://openid.net/specs/openid-connect-core-1_0.html#IDToken)
* [access token](http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse)
* [userinfo response](http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse)
* [Access Token](http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse)
* [Userinfo Response](http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse)

### User logout
## User logout

To support user logout use the `oidc_logout` decorator:
To support user logout, use the `oidc_logout` decorator:
```python
@app.route('/logout')
@auth.oidc_logout
Expand All @@ -108,12 +138,13 @@ def logout():
```

This extension also supports [RP-Initiated Logout](http://openid.net/specs/openid-connect-session-1_0.html#RPLogout),
if the provider allows it.
if the provider allows it. Make sure the `end_session_endpoint` is defined in the provider metadata to enable notifying
the provider when the user logs out.

### Specify the error view
## Specify the error view

If an OAuth error response is received, either in the authentication or token response, it will be passed to the
specified error view. An error view is specified by using the `error_view` decorator:
"error view", specified using the `error_view` decorator:

```python
from flask import jsonify
Expand All @@ -124,25 +155,7 @@ def error(error=None, error_description=None):
```

The function specified as the error view MUST accept two parameters, `error` and `error_description`, which corresponds
to the [OIDC/OAuth error parameters](http://openid.net/specs/openid-connect-core-1_0.html#AuthError).

If no error view is specified a generic error message will be displayed to the user.


## Configuration

The application using this extension MUST set the following [builtin configuration values of Flask](http://flask.pocoo.org/docs/0.10/config/#builtin-configuration-values):

* `SERVER_NAME` (MUST be the same as `<flask_url>` if using static client registration)
* `SECRET_KEY` (this extension relies on [Flask sessions](http://flask.pocoo.org/docs/0.11/quickstart/#sessions), which requires `SECRET_KEY`)

You may also configure the way Flask sessions handles the user session:

* `PERMANENT_SESSION` (added by this extension; makes the session cookie expire after a configurable length of time instead of being tied to the browser session)
* `PERMANENT_SESSION_LIFETIME` (the lifetime of a permanent session)

See the [Flask documentation](http://flask.pocoo.org/docs/0.11/config/#builtin-configuration-values) for an exhaustive list of configuration options.

## Example
to the [OIDC error parameters](http://openid.net/specs/openid-connect-core-1_0.html#AuthError), and return the content
that should be displayed to the user.

Have a look at the example Flask app in [app.py](example/app.py) for an idea of how to use it.
If no error view is specified, a generic error message will be displayed to the user.
Empty file added example/__init__.py
Empty file.
34 changes: 21 additions & 13 deletions example/app.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,39 @@
import flask
import logging
from flask import Flask, jsonify

from flask_pyoidc.flask_pyoidc import OIDCAuthentication
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
from flask_pyoidc.user_session import UserSession

PORT = 5000
app = Flask(__name__)


# See http://flask.pocoo.org/docs/0.12/config/
app.config.update({'SERVER_NAME': 'example.com',
'SECRET_KEY': 'dev_key',
'PREFERRED_URL_SCHEME': 'https',
'SESSION_PERMANENT': True, # turn on flask session support
'PERMANENT_SESSION_LIFETIME': 2592000, # session time in seconds (30 days)
app.config.update({'SERVER_NAME': 'localhost:5000',
'SECRET_KEY': 'dev_key', # make sure to change this!!
'PREFERRED_URL_SCHEME': 'http',
'DEBUG': True})

auth = OIDCAuthentication(app, issuer="auth.example.net")
ISSUER = 'https://provider.example.com'
CLIENT_ID = 'client1'
CLIENT_SECRET = 'very_secret'
provider_configuration = ProviderConfiguration(issuer=ISSUER,
client_metadata=ClientMetadata(CLIENT_ID, CLIENT_SECRET))
auth = OIDCAuthentication(provider_configuration)


@app.route('/')
@auth.oidc_auth
def index():
return jsonify(id_token=flask.session['id_token'], access_token=flask.session['access_token'],
userinfo=flask.session['userinfo'])
user_session = UserSession(flask.session)
return jsonify(access_token=user_session.access_token,
id_token=user_session.id_token,
userinfo=user_session.userinfo)


@app.route('/logout')
@auth.oidc_logout
def logout():
return 'You\'ve been successfully logged out!'
return "You've been successfully logged out!"


@auth.error_view
Expand All @@ -36,4 +42,6 @@ def error(error=None, error_description=None):


if __name__ == '__main__':
app.run(port=PORT)
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
auth.init_app(app)
app.run()
86 changes: 86 additions & 0 deletions example/test_example_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import time

import json
import pytest
import responses
from oic.oic import IdToken
from six.moves.urllib.parse import parse_qsl, urlencode, urlparse

from .app import app, auth, CLIENT_ID, ISSUER


class TestExampleApp(object):
PROVIDER_METADATA = {
'issuer': ISSUER,
'authorization_endpoint': ISSUER + '/auth',
'jwks_uri': ISSUER + '/jwks',
'token_endpoint': ISSUER + '/token',
'userinfo_endpoint': ISSUER + '/userinfo'
}
USER_ID = 'user1'

@pytest.fixture('session', autouse=True)
def setup(self):
app.testing = True

with responses.RequestsMock() as r:
# mock provider discovery
r.add(responses.GET, ISSUER + '/.well-known/openid-configuration', json=self.PROVIDER_METADATA)
auth.init_app(app)

@responses.activate
def perform_authentication(self, client):
# index page should make auth request
auth_redirect = client.get('/')
parsed_auth_request = dict(parse_qsl(urlparse(auth_redirect.location).query))

now = int(time.time())
# mock token response
id_token = IdToken(iss=ISSUER,
aud=CLIENT_ID,
sub=self.USER_ID,
exp=now + 10,
iat=now,
nonce=parsed_auth_request['nonce'])
token_response = {'access_token': 'test_access_token', 'token_type': 'Bearer', 'id_token': id_token.to_jwt()}
responses.add(responses.POST, self.PROVIDER_METADATA['token_endpoint'], json=token_response)

# mock userinfo response
userinfo = {'sub': self.USER_ID, 'name': 'Test User'}
responses.add(responses.GET, self.PROVIDER_METADATA['userinfo_endpoint'], json=userinfo)

# fake auth response sent to redirect URI
fake_auth_response = 'code=fake_auth_code&state={}'.format(parsed_auth_request['state'])
logged_in_page = client.get('/redirect_uri?{}'.format(fake_auth_response), follow_redirects=True)
result = json.loads(logged_in_page.data.decode('utf-8'))

assert result['access_token'] == 'test_access_token'
assert result['id_token'] == id_token.to_dict()
assert result['userinfo'] == {'sub': self.USER_ID, 'name': 'Test User'}

def test_login_logout(self):
client = app.test_client()

self.perform_authentication(client)

response = client.get('/logout')
assert response.data.decode('utf-8') == "You've been successfully logged out!"

def test_error_view(self):
client = app.test_client()

auth_redirect = client.get('/')
parsed_auth_request = dict(parse_qsl(urlparse(auth_redirect.location).query))

# fake auth error response sent to redirect_uri
error_auth_response = {
'error': 'invalid_request',
'error_description': 'test error',
'state': parsed_auth_request['state']
}
error_page = client.get('/redirect_uri?{}'.format(urlencode(error_auth_response)), follow_redirects=True)

assert json.loads(error_page.data.decode('utf-8')) == {
'error': error_auth_response['error'],
'message': error_auth_response['error_description']
}
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
author_email='[email protected]',
description='Flask extension for OpenID Connect authentication.',
install_requires=[
'oic==0.11.0.1',
'Flask'
'oic==0.12',
'Flask',
'requests'
]
)
Loading

0 comments on commit a009ae1

Please sign in to comment.