You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2024/04/30 22:04:00 UTC

(allura) 02/03: [#7272] OAuth2 tests, renaming, improvements, config option

This is an automated email from the ASF dual-hosted git repository.

brondsem pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit 13cfdd53523a4f7267e7c240456c2bc1abf581fc
Author: Carlos Cruz <ca...@slashdotmedia.com>
AuthorDate: Tue Apr 16 16:24:30 2024 +0000

    [#7272] OAuth2 tests, renaming, improvements, config option
---
 Allura/allura/controllers/auth.py                |  17 ++-
 Allura/allura/controllers/rest.py                | 111 ++++++++++------
 Allura/allura/lib/custom_middleware.py           |  11 +-
 Allura/allura/model/__init__.py                  |   4 +-
 Allura/allura/model/oauth.py                     |  42 +++---
 Allura/allura/templates/oauth2_applications.html |   4 +-
 Allura/allura/tests/functional/test_auth.py      | 156 +++++++++++++++++++++++
 Allura/allura/tests/functional/test_rest.py      |   6 +
 Allura/development.ini                           |   3 +
 9 files changed, 288 insertions(+), 66 deletions(-)

diff --git a/Allura/allura/controllers/auth.py b/Allura/allura/controllers/auth.py
index 8f462def0..3f3f82fdb 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -114,7 +114,10 @@ class AuthController(BaseController):
         self.user_info = UserInfoController()
         self.subscriptions = SubscriptionsController()
         self.oauth = OAuthController()
-        self.oauth2 = OAuth2Controller()
+
+        if asbool(config.get('auth.oauth2.enabled', False)):
+            self.oauth2 = OAuth2Controller()
+            
         if asbool(config.get('auth.allow_user_to_disable_account', False)):
             self.disable = DisableAccountController()
 
@@ -1413,19 +1416,19 @@ class OAuth2Controller(BaseController):
 
     def _revoke_all(self, client_id):
         M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
-        M.OAuth2Token.query.remove({'client_id': client_id})
+        M.OAuth2AccessToken.query.remove({'client_id': client_id})
 
     @with_trailing_slash
     @expose('jinja:allura:templates/oauth2_applications.html')
     def index(self, **kw):
         c.form = F.oauth2_application_form
         provider = plugin.AuthenticationProvider.get(request)
-        clients = M.OAuth2Client.for_user(c.user)
+        clients = M.OAuth2ClientApp.for_owner(c.user)
         model = []
 
         for client in clients:
             authorization = M.OAuth2AuthorizationCode.query.get(client_id=client.client_id)
-            token = M.OAuth2Token.query.get(client_id=client.client_id)
+            token = M.OAuth2AccessToken.query.get(client_id=client.client_id)
             model.append(dict(client=client, authorization=authorization, token=token))
 
         return dict(
@@ -1437,7 +1440,7 @@ class OAuth2Controller(BaseController):
     @require_post()
     @validate(F.oauth2_application_form, error_handler=index)
     def register(self, application_name=None, application_description=None, redirect_url=None, **kw):
-        M.OAuth2Client(name=application_name,
+        M.OAuth2ClientApp(name=application_name,
                        description=application_description,
                        redirect_uris=[redirect_url])
         flash('Oauth2 Client registered')
@@ -1446,8 +1449,8 @@ class OAuth2Controller(BaseController):
     @expose()
     @require_post()
     def do_client_action(self, _id=None, deregister=None, revoke=None):
-        client = M.OAuth2Client.query.get(client_id=_id)
-        if client is None or client.user_id != c.user._id:
+        client = M.OAuth2ClientApp.query.get(client_id=_id)
+        if client is None or client.owner_id != c.user._id:
             flash('Invalid client ID', 'error')
             redirect('.')
 
diff --git a/Allura/allura/controllers/rest.py b/Allura/allura/controllers/rest.py
index e77c8bea9..b16ae0dec 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -16,6 +16,7 @@
 #       under the License.
 
 """REST Controller"""
+from __future__ import annotations
 import json
 import logging
 from datetime import datetime, timedelta
@@ -52,11 +53,16 @@ class RestController:
 
     def __init__(self):
         self.oauth = OAuthNegotiator()
-        self.oauth2 = Oauth2Negotiator()
         self.auth = AuthRestController()
 
+        if self._is_oauth2_enabled():
+            self.oauth2 = Oauth2Negotiator()
+
+    def _is_oauth2_enabled(self):
+        return asbool(config.get('auth.oauth2.enabled', False))
+
     def _check_security(self):
-        if not request.path.startswith('/rest/oauth/'):  # everything but OAuthNegotiator
+        if not request.path.startswith(('/rest/oauth/', '/rest/oauth2/')):  # everything but OAuthNegotiators
             c.api_token = self._authenticate_request()
             if c.api_token:
                 c.user = c.api_token.user
@@ -67,7 +73,17 @@ class RestController:
         params_auth = 'oauth_token' in request.params
         params_auth = params_auth or 'access_token' in request.params
         if headers_auth or params_auth:
-            return self.oauth._authenticate()
+            try:
+                access_token = self.oauth._authenticate()
+            except exc.HTTPUnauthorized:
+                if not self._is_oauth2_enabled():
+                    raise
+
+                access_token = self.oauth2._authenticate()
+                if not access_token:
+                    raise
+
+            return access_token
         else:
             return None
 
@@ -239,20 +255,21 @@ class Oauth1Validator(oauthlib.oauth1.RequestValidator):
 
 class Oauth2Validator(oauthlib.oauth2.RequestValidator):
     def validate_client_id(self, client_id: str, request: oauthlib.common.Request) -> bool:
-        return M.OAuth2Client.query.get(client_id=client_id) is not None
+        return M.OAuth2ClientApp.query.get(client_id=client_id) is not None
 
     def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
-        return True
+        client = M.OAuth2ClientApp.query.get(client_id=client_id)
+        return redirect_uri in client.redirect_uris
 
     def validate_response_type(self, client_id: str, response_type: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
-        res_type = M.OAuth2Client.query.get(client_id=client_id).response_type
+        res_type = M.OAuth2ClientApp.query.get(client_id=client_id).response_type
         return res_type == response_type
 
     def validate_scopes(self, client_id: str, scopes, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
         return True
 
     def validate_grant_type(self, client_id: str, grant_type: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
-        return True
+        return grant_type in ['authorization_code', 'refresh_token', 'client_credentials']
 
     def get_default_scopes(self, client_id: str, request: oauthlib.common.Request, *args, **kwargs):
         return []
@@ -261,60 +278,63 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
         return request.uri
 
     def invalidate_authorization_code(self, client_id: str, code: str, request: oauthlib.common.Request, *args, **kwargs) -> None:
-        M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
+        M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 'authorization_code': code})
 
     def authenticate_client(self, request: oauthlib.common.Request, *args, **kwargs) -> bool:
         client_id = request.body['client_id']
-        client = M.OAuth2Client.query.get(client_id=client_id)
-        if not client:
-            return False
-
-        request.client = client
-        return True
+        request.client = M.OAuth2ClientApp.query.get(client_id=client_id)
+        return request.client is not None
 
     def validate_code(self, client_id: str, code: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
-        authorization = M.OAuth2AuthorizationCode.query.get({'client_id': client_id})
-        return authorization.expires_at <= datetime.utcnow()
+        authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id, authorization_code=code)
+        return authorization.expires_at >= datetime.utcnow() if authorization else False
+
+    def validate_bearer_token(self, token: str, scopes: list[str], request: oauthlib.common.Request) -> bool:
+        access_token = M.OAuth2AccessToken.query.get(access_token=token)
+        return access_token.expires_at >= datetime.utcnow() if access_token else False
 
     def confirm_redirect_uri(self, client_id: str, code: str, redirect_uri: str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> bool:
         return True
 
     def save_authorization_code(self, client_id: str, code, request: oauthlib.common.Request, *args, **kwargs) -> None:
-        authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id)
+        authorization = M.OAuth2AuthorizationCode.query.get(client_id=client_id, authorization_code=code['code'])
+        log.info('Saving authorization code for client: %s', client_id)
 
         if not authorization:
             auth_code = M.OAuth2AuthorizationCode(
-                client_id = client_id,
-                authorization_code = code['code'],
-                expires_at = datetime.utcnow() + timedelta(minutes=10)
+                client_id=client_id,
+                authorization_code=code['code'],
+                expires_at=datetime.utcnow() + timedelta(minutes=10),
+                redirect_uri=request.redirect_uri,
+                owner_id=c.user._id
             )
             session(auth_code).flush()
             log.info(f'Saving new authorization code for client: {client_id}')
         else:
             log.info(f'Updating authorization code for {client_id}')
-            log.info(f'Current authorization code: {authorization.authorization_code}')
-            log.info(f'New authorization code: {code["code"]}')
             M.OAuth2AuthorizationCode.query.update(
                 {'client_id': client_id},
                 {'$set': {'authorization_code': code['code'], 'expires_at': datetime.utcnow() + timedelta(minutes=10)}})
             log.info(f'Updating authorization code for client: {client_id}')
 
     def save_bearer_token(self, token, request: oauthlib.common.Request, *args, **kwargs) -> object:
-        current_token = M.OAuth2Token.query.get(client_id=request.client_id)
+        current_token = M.OAuth2AccessToken.query.get(client_id=request.client_id, token=token.get('access_token'))
+        client = M.OAuth2ClientApp.query.get(client_id=request.client_id)
 
         if not current_token:
-            bearer_token = M.OAuth2Token(
+            bearer_token = M.OAuth2AccessToken(
                 client_id = request.client_id,
                 scopes = token.get('scope', []),
                 access_token = token.get('access_token'),
                 refresh_token = token.get('refresh_token'),
-                expires_at = datetime.utcfromtimestamp(token.get('expires_in'))
+                expires_at = datetime.utcfromtimestamp(token.get('expires_in')),
+                owner_id = client.owner_id
             )
 
             session(bearer_token).flush()
             log.info(f'Saving new bearer token for client: {request.client_id}')
         else:
-            M.OAuth2Token.query.update(
+            M.OAuth2AccessToken.query.update(
                 {'client_id': request.client_id},
                 {'$set': {'access_token': token.get('access_token'), 'expires_at': datetime.utcfromtimestamp(token.get('expires_in')), 'refresh_token': token.get('refresh_token')}})
             log.info(f'Updating bearer token for client: {request.client_id}')
@@ -461,11 +481,30 @@ class Oauth2Negotiator:
     def server(self):
         return oauthlib.oauth2.WebApplicationServer(Oauth2Validator())
 
+    def _authenticate(self):
+        bearer_token_prefix = 'Bearer ' # noqa: S105
+        auth_header = request.headers.get('Authorization')
+        if auth_header and auth_header.startswith(bearer_token_prefix):
+            access_token = auth_header[len(bearer_token_prefix):]
+
+        valid, req = self.server.verify_request(
+            request.url,
+            http_method=request.method,
+            body=request.body,
+            headers=request.headers)
+
+        if not valid:
+            raise exc.HTTPUnauthorized
+
+        access_token = M.OAuth2AccessToken.query.get(access_token=req.access_token)
+        access_token.last_access = datetime.utcnow()
+        return access_token
+
+
     @expose('jinja:allura:templates/oauth2_authorize.html')
     def authorize(self, **kwargs):
         security.require_authenticated()
         json_body = None
-
         if request.body:
             # We need to decode the request body and convert it to a dict because Turbogears creates it as bytes
             # and oauthlib will treat it as x-www-form-urlencoded format.
@@ -475,18 +514,15 @@ class Oauth2Negotiator:
         try:
             scopes, credentials = self.server.validate_authorization_request(uri=request.url, http_method=request.method, headers=request.headers, body=json_body)
             client_id = request.params.get('client_id')
-            client = M.OAuth2Client.query.get(client_id=client_id)
+            client = M.OAuth2ClientApp.query.get(client_id=client_id)
 
             # We need to save the credentials to the current session so we can use it later in the POST request.
             # We also need to use __dict__ because the internal oauthlib request object cannot be directly serialized
             # and saved to Ming
             credentials['request'] = credentials['request'].__dict__
-            M.OAuth2Client.set_credentials(client_id, credentials)
+            M.OAuth2ClientApp.set_credentials(client_id, credentials)
 
-            return dict(
-                credentials=credentials,
-                client=client
-            )
+            return dict(client=client)
         except Exception as e:
             log.exception(e)
 
@@ -496,7 +532,7 @@ class Oauth2Negotiator:
         security.require_authenticated()
 
         client_id = request.params['client_id']
-        client = M.OAuth2Client.query.get(client_id=client_id)
+        client = M.OAuth2ClientApp.query.get(client_id=client_id)
 
         if no:
             flash(f'{client.name} NOT AUTHORIZED', 'error')
@@ -507,8 +543,11 @@ class Oauth2Negotiator:
                 uri=request.url, http_method=request.method, body=request.body, headers=request.headers, scopes=[], credentials=client.credentials
             )
 
-            qs_params = dict(parse_qsl(headers['Location']))
-            return dict(client=client, authorization_code=qs_params.get('code', ''))
+            response.status_int = status
+            for k, v in headers.items():
+                response.headers[k] = v
+
+            return body
         except Exception as ex:
             log.exception(ex)
 
diff --git a/Allura/allura/lib/custom_middleware.py b/Allura/allura/lib/custom_middleware.py
index d9ca652fb..e5c64d497 100644
--- a/Allura/allura/lib/custom_middleware.py
+++ b/Allura/allura/lib/custom_middleware.py
@@ -495,10 +495,13 @@ class ContentSecurityPolicyMiddleware:
             srcs = self.config['csp.form_action_urls']
             if environ.get('csp_form_actions'):
                 srcs += ' ' + ' '.join(environ['csp_form_actions'])
-            if asbool(self.config.get('csp.form_actions_enforce', False)):
-                rules.add(f"form-action {srcs}")
-            else:
-                report_rules.add(f"form-action {srcs}")
+
+            oauth_endpoints = ('/rest/oauth2/authorize', '/rest/oauth2/do_authorize', '/rest/oauth/authorize', '/rest/oauth/do_authorize')
+            if not req.path.startswith(oauth_endpoints): # Do not enforce CSP for OAuth1 and OAuth2 authorization
+                if asbool(self.config.get('csp.form_actions_enforce', False)):
+                    rules.add(f"form-action {srcs}")
+                else:
+                    report_rules.add(f"form-action {srcs}")
 
         if self.config.get('csp.script_src'):
             script_srcs = self.config['csp.script_src']
diff --git a/Allura/allura/model/__init__.py b/Allura/allura/model/__init__.py
index dd7511f94..abbf624a4 100644
--- a/Allura/allura/model/__init__.py
+++ b/Allura/allura/model/__init__.py
@@ -31,7 +31,7 @@ from .notification import Notification, Mailbox, SiteNotification
 from .repository import Repository, RepositoryImplementation, CommitStatus
 from .repository import MergeRequest, GitLikeTree
 from .stats import Stats
-from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken, OAuth2Client, OAuth2AuthorizationCode, OAuth2Token
+from .oauth import OAuthToken, OAuthConsumerToken, OAuthRequestToken, OAuthAccessToken, OAuth2ClientApp, OAuth2AuthorizationCode, OAuth2AccessToken
 from .monq_model import MonQTask
 from .webhook import Webhook
 from .multifactor import TotpKey
@@ -56,7 +56,7 @@ __all__ = [
     'DiscussionAttachment', 'BaseAttachment', 'AuthGlobals', 'User', 'ProjectRole', 'EmailAddress',
     'AuditLog', 'AlluraUserProperty', 'File', 'Notification', 'Mailbox', 'Repository',
     'RepositoryImplementation', 'CommitStatus', 'MergeRequest', 'GitLikeTree', 'Stats', 'OAuthToken', 'OAuthConsumerToken',
-    'OAuthRequestToken', 'OAuthAccessToken', 'OAuth2Client', 'OAuth2AuthorizationCode', 'OAuth2Token', 'MonQTask', 'Webhook',
+    'OAuthRequestToken', 'OAuthAccessToken', 'OAuth2ClientApp', 'OAuth2AuthorizationCode', 'OAuth2AccessToken', 'MonQTask', 'Webhook',
     'ACE', 'ACL', 'EVERYONE', 'ALL_PERMISSIONS', 'DENY_ALL', 'MarkdownCache', 'main_doc_session', 'main_orm_session',
     'project_doc_session', 'project_orm_session', 'artifact_orm_session', 'repository_orm_session', 'task_orm_session',
     'ArtifactSessionExtension', 'repository', 'repo_refresh', 'SiteNotification', 'TotpKey', 'UserLoginDetails',
diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py
index 08791f76e..f6ddcadbc 100644
--- a/Allura/allura/model/oauth.py
+++ b/Allura/allura/model/oauth.py
@@ -154,29 +154,34 @@ class OAuthAccessToken(OAuthToken):
         return False
 
 
-class OAuth2Client(MappedClass):
+class OAuth2ClientApp(MappedClass):
     class __mongometa__:
         session = main_orm_session
-        name = 'oauth2_client'
+        name = 'oauth2_client_app'
+        unique_indexes = [('client_id', 'owner_id')]
 
-    query: 'Query[OAuth2Client]'
+    query: 'Query[OAuth2ClientApp]'
 
     _id = FieldProperty(S.ObjectId)
     client_id = FieldProperty(str, if_missing=lambda: h.nonce(20))
     credentials = FieldProperty(S.Anything)
     name = FieldProperty(str)
     description = FieldProperty(str, if_missing='')
-    user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+    description_cache = FieldProperty(MarkdownCache)
+    owner_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
     grant_type = FieldProperty(str, if_missing='authorization_code')
     response_type = FieldProperty(str, if_missing='code')
     scopes = FieldProperty([str])
     redirect_uris = FieldProperty([str])
 
+    owner = RelationProperty('User')
+
+
     @classmethod
-    def for_user(cls, user=None):
-        if user is None:
-            user = c.user
-        return cls.query.find(dict(user_id=user._id)).all()
+    def for_owner(cls, owner=None):
+        if owner is None:
+            owner = c.user
+        return cls.query.find(dict(owner_id=owner._id)).all()
 
     @classmethod
     def set_credentials(cls, client_id, credentials):
@@ -191,35 +196,42 @@ class OAuth2AuthorizationCode(MappedClass):
     class __mongometa__:
         session = main_orm_session
         name = 'oauth2_authorization_code'
+        unique_indexes = [('authorization_code', 'client_id', 'owner_id')]
 
     query: 'Query[OAuth2AuthorizationCode]'
 
     _id = FieldProperty(S.ObjectId)
     client_id = FieldProperty(str)
-    user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+    owner_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
     scopes = FieldProperty([str])
     redirect_uri = FieldProperty(str)
     authorization_code = FieldProperty(str)
     expires_at = FieldProperty(S.DateTime)
     # For PKCE support
-    challenge = FieldProperty(str)
-    challenge_method = FieldProperty(str)
+    code_challenge = FieldProperty(str)
+    code_challenge_method = FieldProperty(str)
+
+    owner = RelationProperty('User')
 
 
-class OAuth2Token(MappedClass):
+class OAuth2AccessToken(MappedClass):
     class __mongometa__:
         session = main_orm_session
-        name = 'oauth2_token'
+        name = 'oauth2_access_token'
+        unique_indexes = [('access_token', 'client_id', 'owner_id')]
 
-    query: 'Query[OAuth2Token]'
+    query: 'Query[OAuth2AccessToken]'
 
     _id = FieldProperty(S.ObjectId)
     client_id = FieldProperty(str)
-    user_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
+    owner_id: ObjectId = AlluraUserProperty(if_missing=lambda: c.user._id)
     scopes = FieldProperty([str])
     access_token = FieldProperty(str)
     refresh_token = FieldProperty(str)
     expires_at = FieldProperty(S.DateTime)
+    last_access = FieldProperty(datetime)
+
+    owner = RelationProperty('User')
 
 
 def dummy_oauths():
diff --git a/Allura/allura/templates/oauth2_applications.html b/Allura/allura/templates/oauth2_applications.html
index cb16be3db..510342edf 100644
--- a/Allura/allura/templates/oauth2_applications.html
+++ b/Allura/allura/templates/oauth2_applications.html
@@ -96,14 +96,14 @@
         <tr class="description"><th>Description:</th><td>{{app.client.description }}</td></tr>
         <tr class="client_id"><th>Client ID:</th><td>{{app.client.client_id}}</td></tr>
         <tr class="redirect_url"><th>Redirect URL:</th><td>{{app.client.redirect_uris[0] if app.client.redirect_uris else ''}}</td></tr>
-        <tr class="grant_type"><th>Grant Type:</th><td>{{app.client.grant_type}}</td></tr>
 
         {% if app.authorization %}
+            <tr class="grant_type"><th>Grant Type:</th><td>{{app.client.grant_type}}</td></tr>
             <tr class="authorization_code"><th>Authorization Code:</th><td>{{app.authorization.authorization_code}}</td></tr>
             <tr class="authorization_code_expires"><th>Authorization Code Expires At:</th><td>{{app.authorization.expires_at.strftime('%Y-%m-%d %H:%M:%S')}} UTC</td></tr>
         {% endif %}
 
-        {% if app.access_token %}
+        {% if app.token %}
             <tr class="access_token"><th>Access Token:</th><td>{{app.token.access_token}}</td></tr>
             <tr class="access_token_expires"><th>Access Token Expires At:</th><td>{{app.token.expires_at.strftime('%Y-%m-%d %H:%M:%S')}} UTC</td></tr>
             <tr class="refresh_token"><th>Refresh Token:</th><td>{{app.token.refresh_token}}</td></tr>
diff --git a/Allura/allura/tests/functional/test_auth.py b/Allura/allura/tests/functional/test_auth.py
index 10dbd380d..2ed7b06f4 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -2067,6 +2067,162 @@ class TestOAuth(TestController):
         assert r.location.startswith(url)
 
 
+class TestOAuth2(TestController):
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_register_deregister_client(self):
+        #register
+        r = self.app.get('/auth/oauth2/')
+        r = self.app.post('/auth/oauth2/register',
+                          params={'application_name': 'testoauth2', 'application_description': 'Oauth2 Test',
+                                    'redirect_url': '', '_session_id': self.app.cookies['_session_id'],
+                                  }).follow()
+
+        assert 'testoauth2' in r
+
+        #deregister
+        assert r.forms[0].action == 'do_client_action'
+        r.forms[0].submit('deregister')
+        r = self.app.get('/auth/oauth2/')
+        assert 'testoauth2' not in r
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_authorize(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            owner_id=user._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+        ThreadLocalODMSession.flush_all()
+        r = self.app.get('/rest/oauth2/authorize/', params={'client_id': 'client_12345', 'response_type': 'code'})
+        assert 'testoauth2' in r.text
+        assert 'client_12345' in r.text
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_do_authorize_no(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            owner_id=user._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+        ThreadLocalODMSession.flush_all()
+        r = self.app.post('/rest/oauth2/do_authorize', params={'no': '1', 'client_id': 'client_12345', 'response_type': 'code'})
+        assert M.OAuth2AuthorizationCode.query.get(client_id='client_12345') is None
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_do_authorize(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            owner_id=user._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+        ThreadLocalODMSession.flush_all()
+
+        # First navigate to the authorization page for the backend to validate the authorization request
+        r = self.app.get('/rest/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+        # The submit authorization for the authorization code to be created
+        r = self.app.post('/rest/oauth2/do_authorize', params={'yes': '1', 'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+        q = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+        assert q is not None
+
+        r = self.app.get('/auth/oauth2/')
+        assert 'Authorization Code:' in r
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_create_access_token(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            owner_id=user._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+        ThreadLocalODMSession.flush_all()
+
+        # First navigate to the authorization page for the backend to validate the authorization request
+        r = self.app.get('/rest/oauth2/authorize', params={'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+        # The submit authorization for the authorization code to be created
+        r = self.app.post('/rest/oauth2/do_authorize', params={'yes': '1', 'client_id': 'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+
+        ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+        assert ac is not None
+
+        r = self.app.get('/auth/oauth2/')
+        assert 'Authorization Code:' in r
+
+        # Create the authorization token
+        oauth2_params = dict(
+            client_id='client_12345',
+            code=ac.authorization_code,
+            grant_type='authorization_code'
+        )
+        r = self.app.post_json('/rest/oauth2/token', oauth2_params)
+        t = M.OAuth2AccessToken.query.get(client_id='client_12345')
+        assert t is not None
+        assert t.access_token is not None and t.refresh_token is not None
+
+        r = self.app.get('/auth/oauth2/')
+        assert 'Access Token:' in r
+        assert 'Refresh Token:' in r
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_revoke_tokens(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            owner_id=user._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+
+        M.OAuth2AuthorizationCode(
+            client_id='client_12345',
+            authotization_code='authcode_12345',
+            expires_at=datetime.utcnow() + timedelta(minutes=10),
+            owner_id=user._id,
+        )
+
+        M.OAuth2AccessToken(
+            client_id='client_12345',
+            access_token='12345',
+            refresh_token='54321',
+            expires_at=datetime.utcnow() + timedelta(minutes=20),
+            owner_id=user._id,
+        )
+
+        ThreadLocalODMSession.flush_all()
+
+        r = self.app.get('/auth/oauth2/')
+        assert 'authorization code' in r
+        assert 'access token' in r
+        assert r.forms[0].action == 'do_client_action'
+
+        r.forms[0].submit('revoke')
+
+        r = self.app.get('/auth/oauth2/')
+        assert 'testoauth2' in r
+        assert 'Authorization Code:' not in r
+        assert 'Access Token:' not in r
+
+
 class TestOAuthRequestToken(TestController):
 
     oauth_params = dict(
diff --git a/Allura/allura/tests/functional/test_rest.py b/Allura/allura/tests/functional/test_rest.py
index 5ee8a45ff..3cac55f34 100644
--- a/Allura/allura/tests/functional/test_rest.py
+++ b/Allura/allura/tests/functional/test_rest.py
@@ -46,6 +46,7 @@ class TestRestHome(TestRestApiBase):
         request.params = {'access_token': 'foo'}
         request.scheme = 'https'
         request.path = '/rest/p/test/wiki'
+        request.url = 'https://localhost/rest/p/test/wiki'
         self._patch_token(OAuthAccessToken)
         access_token = OAuthAccessToken.query.get.return_value
         access_token.is_bearer = False
@@ -59,6 +60,7 @@ class TestRestHome(TestRestApiBase):
         request.params = {'access_token': 'foo'}
         request.scheme = 'https'
         request.path = '/rest/p/test/wiki'
+        request.url = 'https://localhost/rest/p/test/wiki'
         self._patch_token(OAuthAccessToken)
         OAuthAccessToken.query.get.return_value = None
         r = self.api_post('/rest/p/test/wiki', access_token='foo', status=401)
@@ -89,6 +91,7 @@ class TestRestHome(TestRestApiBase):
         request.params = {'access_token': access_token.api_key}
         request.scheme = 'https'
         request.path = '/rest/p/test/wiki'
+        request.url = 'https://localhost/rest/p/test/wiki'
         r = self.api_post('/rest/p/test/wiki', access_token='foo')
         assert r.status_int == 200
 
@@ -100,6 +103,7 @@ class TestRestHome(TestRestApiBase):
         }
         request.scheme = 'https'
         request.path = '/rest/p/test/wiki'
+        request.url = 'https://localhost/rest/p/test/wiki'
         self._patch_token(OAuthAccessToken)
         access_token = OAuthAccessToken.query.get.return_value
         access_token.is_bearer = False
@@ -114,6 +118,7 @@ class TestRestHome(TestRestApiBase):
         }
         request.scheme = 'https'
         request.path = '/rest/p/test/wiki'
+        request.url = 'https://localhost/rest/p/test/wiki'
         self._patch_token(OAuthAccessToken)
         OAuthAccessToken.query.get.return_value = None
         r = self.api_post('/rest/p/test/wiki', access_token='foo', status=401)
@@ -147,6 +152,7 @@ class TestRestHome(TestRestApiBase):
         }
         request.scheme = 'https'
         request.path = '/rest/p/test/wiki'
+        request.url = 'https://localhost/rest/p/test/wiki'
         r = self.api_post('/rest/p/test/wiki', access_token='foo', status=200)
         # reverse proxy situation
         request.scheme = 'http'
diff --git a/Allura/development.ini b/Allura/development.ini
index 4cad5509a..424538d3f 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -240,6 +240,9 @@ auth.hibp_failure_force_pwd_change = true
 ; HIBP-listed password without going through email
 auth.auth.trust_ip_3_octets_match = true
 
+; Enable OAuth2 support
+auth.oauth2.enabled = true
+
 user_prefs_storage.method = local
 ; user_prefs_storage.method = ldap
 ; If using ldap, you can specify which fields to use for a preference.