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


The following commit(s) were added to refs/heads/master by this push:
     new 8a69cd0a2 [#7272] Add custom bearer token feature and other fixes
8a69cd0a2 is described below

commit 8a69cd0a2a74094686ed44f2ec7ebf2831ddf75e
Author: Carlos Cruz <carlos.c...@slashdotmedia.com>
AuthorDate: Fri May 17 17:35:13 2024 +0000

    [#7272] Add custom bearer token feature and other fixes
---
 Allura/allura/controllers/auth.py                |  78 ++++++++-
 Allura/allura/controllers/rest.py                |  57 +------
 Allura/allura/lib/custom_middleware.py           |   2 +-
 Allura/allura/model/oauth.py                     |  19 ++-
 Allura/allura/templates/oauth2_applications.html | 128 ---------------
 Allura/allura/templates/oauth2_authorize_ok.html |  35 ----
 Allura/allura/templates/oauth_applications.html  |  11 +-
 Allura/allura/tests/functional/test_auth.py      | 198 ++++++++++++++++++++++-
 Allura/allura/tests/functional/test_rest.py      |  47 ++++++
 Allura/docs/api-rest/docs.md                     |   4 +-
 Allura/docs/api-rest/securitySchemes.yaml        |   6 +-
 AlluraTest/alluratest/controller.py              |   7 +-
 scripts/wiki-copy.py                             |   2 +-
 13 files changed, 358 insertions(+), 236 deletions(-)

diff --git a/Allura/allura/controllers/auth.py 
b/Allura/allura/controllers/auth.py
index 2ab770a69..1699f6447 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import logging
+import json
 import os
 from base64 import b32encode
 from datetime import datetime
@@ -63,6 +64,8 @@ from allura.lib import utils
 from allura.controllers import BaseController
 from allura.tasks.mail_tasks import send_system_mail_to_user
 import six
+import oauthlib.oauth2
+
 
 log = logging.getLogger(__name__)
 
@@ -116,6 +119,9 @@ class AuthController(BaseController):
         self.subscriptions = SubscriptionsController()
         self.oauth = OAuthController()
 
+        if asbool(config.get('auth.oauth2.enabled', False)):
+            self.oauth2 = OAuth2AuthorizationController()
+
         if asbool(config.get('auth.allow_user_to_disable_account', False)):
             self.disable = DisableAccountController()
 
@@ -1346,12 +1352,12 @@ class OAuthController(BaseController):
                 access_tokens.append({
                     'type': 2,
                     'app': 
M.OAuth2ClientApp.query.get(client_id=oauth2_tok.client_id),
-                    # TODO personal bearer tokens:
-                    'is_bearer': False,
-                    'api_key': None,
+                    'is_bearer': oauth2_tok.is_bearer,
+                    'api_key': oauth2_tok.access_token if oauth2_tok.is_bearer 
else None,
                     'last_access': oauth2_tok.last_access,
                     '_id': oauth2_tok._id,
                 })
+
             # include auth codes too, but only if they're not already listed 
via an access/refresh token
             for oauth2_auth in 
M.OAuth2AuthorizationCode.query.find({'user_id': c.user._id}):
                 client_app = 
M.OAuth2ClientApp.query.get(client_id=oauth2_auth.client_id)
@@ -1489,6 +1495,22 @@ class OAuthController(BaseController):
             flash('Invalid token ID', 'error')
             redirect('.')
 
+    @expose()
+    @require_post()
+    def generate_bearer_token(self, client_id):
+        """
+        Manually generates an OAuth2 access token without needing to go 
through the OAuth2 flow.
+        """
+        M.OAuth2AccessToken(
+            client_id=client_id,
+            user_id=c.user._id,
+            access_token=h.nonce(40),
+            is_bearer=True,
+            expires_at=datetime.max
+        )
+
+        redirect('.')
+
     @expose()
     @require_post()
     def revoke_access_token(self, _id):
@@ -1526,6 +1548,56 @@ class OAuthController(BaseController):
         flash('Authorization revoked')
         redirect('.')
 
+class OAuth2AuthorizationController(BaseController):
+    def _check_security(self):
+        require_authenticated()
+
+    @property
+    def server(self):
+        from allura.controllers.rest import Oauth2Validator
+        return oauthlib.oauth2.WebApplicationServer(Oauth2Validator())
+
+    @expose('jinja:allura:templates/oauth2_authorize.html')
+    @without_trailing_slash
+    def authorize(self, **kwargs):
+        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.
+            decoded_body = str(request.body, 'utf-8')
+            json_body = json.loads(decoded_body)
+
+        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.OAuth2ClientApp.query.get(client_id=client_id)
+
+        # The credentials object has a request object that it's too big to be 
serialized,
+        # so we remove it because we don't need it for the rest of the 
authorization workflow
+        del credentials['request']
+
+        return dict(client=client, credentials=json.dumps(credentials))
+
+    @expose()
+    @require_post()
+    def do_authorize(self, yes=None, no=None):
+        client_id = request.params['client_id']
+        client = M.OAuth2ClientApp.query.get(client_id=client_id)
+
+        if no:
+            flash(f'{client.name} NOT AUTHORIZED', 'error')
+            redirect('/auth/oauth/')
+
+        credentials = json.loads(request.params['credentials'])
+        headers, body, status = self.server.create_authorization_response(
+            uri=request.url, http_method=request.method, body=request.body, 
headers=request.headers, scopes=[], credentials=credentials
+        )
+
+        response.status_int = status
+        response.headers.update(headers)
+        return body
+
+
 class DisableAccountController(BaseController):
 
     def _check_security(self):
diff --git a/Allura/allura/controllers/rest.py 
b/Allura/allura/controllers/rest.py
index 0d4fbe60e..bfa95a4bf 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -255,7 +255,7 @@ class Oauth1Validator(oauthlib.oauth1.RequestValidator):
 
 
 class Oauth2Validator(oauthlib.oauth2.RequestValidator):
-    def validate_client_id(self, client_id: str, request: 
oauthlib.common.Request) -> bool:
+    def validate_client_id(self, client_id: str, request: 
oauthlib.common.Request, *args, **kwargs) -> bool:
         return M.OAuth2ClientApp.query.get(client_id=client_id) is not None
 
     def validate_redirect_uri(self, client_id, redirect_uri, request, *args, 
**kwargs):
@@ -270,7 +270,7 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
         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 grant_type in ['authorization_code', 'refresh_token', 
'client_credentials']
+        return grant_type in ['authorization_code', 'refresh_token']
 
     def get_default_scopes(self, client_id: str, request: 
oauthlib.common.Request, *args, **kwargs):
         return []
@@ -313,7 +313,7 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
             return False
 
     def validate_refresh_token(self, refresh_token: str, client: 
oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, **kwargs) -> 
bool:
-        return M.OAuth2AccessToken.query.get(refresh_token=refresh_token) is 
not None
+        return M.OAuth2AccessToken.query.get(refresh_token=refresh_token, 
client_id=client.client_id) is not None
 
     def confirm_redirect_uri(self, client_id: str, code: str, redirect_uri: 
str, client: oauthlib.oauth2.Client, request: oauthlib.common.Request, *args, 
**kwargs) -> bool:
         # This method is called when the client is exchanging the 
authorization code for an access token.
@@ -322,11 +322,11 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
         return authorization.redirect_uri == redirect_uri
 
     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, user_id=c.user._id, 
authorization_code=code['code'])
+        authorization = 
M.OAuth2AuthorizationCode.query.get(client_id=client_id, user_id=c.user._id)
 
         # Remove the existing authorization code if it exists and create a new 
record
         if authorization:
-            M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 
'user_id': c.user._id, 'authorization_code': code['code']})
+            M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 
'user_id': c.user._id})
 
         log.info('Saving authorization code for client: %s', client_id)
         auth_code = M.OAuth2AuthorizationCode(
@@ -347,10 +347,10 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
         elif request.grant_type == 'refresh_token':
             user_id = 
M.OAuth2AccessToken.query.get(client_id=request.client_id, 
refresh_token=request.refresh_token).user_id
 
-        current_token = 
M.OAuth2AccessToken.query.get(client_id=request.client_id, user_id=user_id)
+        current_token = 
M.OAuth2AccessToken.query.get(client_id=request.client_id, user_id=user_id, 
is_bearer=False)
 
         if current_token:
-            M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 
'user_id': user_id})
+            M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 
'user_id': user_id, 'is_bearer': False})
 
         bearer_token = M.OAuth2AccessToken(
             client_id=request.client_id,
@@ -520,49 +520,6 @@ class Oauth2Negotiator:
         token.last_access = datetime.utcnow()
         return token
 
-    @expose('jinja:allura:templates/oauth2_authorize.html')
-    @without_trailing_slash
-    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.
-            decoded_body = str(request.body, 'utf-8')
-            json_body = json.loads(decoded_body)
-
-        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.OAuth2ClientApp.query.get(client_id=client_id)
-
-        # The credentials object has a request object that it's too big to be 
serialized,
-        # so we remove it because we don't need it for the rest of the 
authorization workflow
-        del credentials['request']
-
-        return dict(client=client, credentials=json.dumps(credentials))
-
-    @expose('jinja:allura:templates/oauth2_authorize_ok.html')
-    @require_post()
-    def do_authorize(self, yes=None, no=None):
-        security.require_authenticated()
-
-        client_id = request.params['client_id']
-        client = M.OAuth2ClientApp.query.get(client_id=client_id)
-
-        if no:
-            flash(f'{client.name} NOT AUTHORIZED', 'error')
-            redirect('/auth/oauth/')
-
-        credentials = json.loads(request.params['credentials'])
-        headers, body, status = self.server.create_authorization_response(
-            uri=request.url, http_method=request.method, body=request.body, 
headers=request.headers, scopes=[], credentials=credentials
-        )
-
-        response.status_int = status
-        response.headers.update(headers)
-        return body
-
     @expose('json:')
     @require_post()
     def token(self, **kwargs):
diff --git a/Allura/allura/lib/custom_middleware.py 
b/Allura/allura/lib/custom_middleware.py
index 4a09325a5..e06f950c1 100644
--- a/Allura/allura/lib/custom_middleware.py
+++ b/Allura/allura/lib/custom_middleware.py
@@ -497,7 +497,7 @@ class ContentSecurityPolicyMiddleware:
                 srcs += ' ' + ' '.join(environ['csp_form_actions'])
 
             oauth_endpoints = (
-            '/rest/oauth2/authorize', '/rest/oauth2/do_authorize', 
'/rest/oauth/authorize', '/rest/oauth/do_authorize')
+            '/auth/oauth2/authorize', '/auth/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}")
diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py
index b8b21d8a3..69043635a 100644
--- a/Allura/allura/model/oauth.py
+++ b/Allura/allura/model/oauth.py
@@ -158,7 +158,10 @@ class OAuth2ClientApp(MappedClass):
     class __mongometa__:
         session = main_orm_session
         name = 'oauth2_client_app'
-        unique_indexes = [('client_id', 'user_id')]
+        unique_indexes = [
+            ('client_id', 'user_id'),
+            ('client_id')
+        ]
         indexes = [
             ('user_id'),
         ]
@@ -194,7 +197,10 @@ class OAuth2AuthorizationCode(MappedClass):
     class __mongometa__:
         session = main_orm_session
         name = 'oauth2_authorization_code'
-        unique_indexes = [('authorization_code', 'client_id', 'user_id')]
+        unique_indexes = [
+            ('authorization_code', 'client_id', 'user_id'),
+            ('authorization_code')
+        ]
         indexes = [
             ('user_id'),
         ]
@@ -219,7 +225,13 @@ class OAuth2AccessToken(MappedClass):
     class __mongometa__:
         session = main_orm_session
         name = 'oauth2_access_token'
-        unique_indexes = [('access_token', 'client_id', 'user_id')]
+        unique_indexes = [
+            ('access_token', 'client_id', 'user_id'),
+            ('access_token')
+        ]
+        custom_indexes = [
+            dict(fields=('refresh_token',), 
partialFilterExpression={'refresh_token': {'$gt': None}}, unique=True),
+        ]
         indexes = [
             ('user_id'),
         ]
@@ -232,6 +244,7 @@ class OAuth2AccessToken(MappedClass):
     scopes = FieldProperty([str])
     access_token = FieldProperty(str)
     refresh_token = FieldProperty(str)
+    is_bearer = FieldProperty(bool, if_missing=False)
     expires_at = FieldProperty(S.DateTime)
     last_access = FieldProperty(datetime)
 
diff --git a/Allura/allura/templates/oauth2_applications.html 
b/Allura/allura/templates/oauth2_applications.html
deleted file mode 100644
index a0d5f01e0..000000000
--- a/Allura/allura/templates/oauth2_applications.html
+++ /dev/null
@@ -1,128 +0,0 @@
-{#-
-       Licensed to the Apache Software Foundation (ASF) under one
-       or more contributor license agreements.  See the NOTICE file
-       distributed with this work for additional information
-       regarding copyright ownership.  The ASF licenses this file
-       to you under the Apache License, Version 2.0 (the
-       "License"); you may not use this file except in compliance
-       with the License.  You may obtain a copy of the License at
-
-         http://www.apache.org/licenses/LICENSE-2.0
-
-       Unless required by applicable law or agreed to in writing,
-       software distributed under the License is distributed on an
-       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-       KIND, either express or implied.  See the License for the
-       specific language governing permissions and limitations
-       under the License.
--#}
-{% set hide_left_bar = True %}
-{% extends "allura:templates/user_account_base.html" %}
-
-{% block title %}{{c.user.username}} / Applications {% endblock %}
-
-{% block header %}OAuth2 applications registered for {{c.user.username}}{% 
endblock %}
-
-{% block extra_css %}
-<style type="text/css">
-    table {
-        border: 1px solid #e5e5e5;
-    }
-    th {
-        text-align: left;
-        width: 10em;
-        padding: 5px;
-        border: 1px solid #e5e5e5;
-    }
-    tr.description p {
-        padding-left: 0;
-    }
-    tr.description p:last-child {
-        padding-bottom: 0;
-    }
-    tr.controls input[type="submit"] {
-        margin-bottom: 0;
-    }
-</style>
-{% endblock %}
-
-{% block extra_js %}
-<script type="text/javascript">
-    $(function() {
-        var btnClicked;
-
-        // The click event will always trigger before the submit event, this 
will give us the chance to
-        // figure out which button was clicked in order to display the correct 
confirmation dialog
-        $('#deregister,#revoke').click(function(){
-            btnClicked = this.name;
-        });
-
-        $('.client_action').submit(function(e) {
-            var confirmMsg;
-
-            if(btnClicked === 'deregister') {
-                confirmMsg = 'Deregister client?. This action will also revoke 
authorization and access tokens.'
-            }
-
-            if(btnClicked === 'revoke') {
-                confirmMsg = "Revoke client's authorization codes and access 
tokens?. This action will not delete the current client."
-            }
-
-            var ok = confirm(confirmMsg)
-            if(!ok) {
-                e.preventDefault();
-                return false;
-            }
-        });
-    })
-</script>
-{% endblock %}
-
-{% block content %}
-    {{ super() }}
-
-    <h2>My Clients</h2>
-    <p>
-    These are the clients you have registered.  They can request authorization
-    for a user by sending the client id and a response type.
-    Once you have an authorization code, you can generate an access token to 
give your client access
-    to your account and use a refresh token to generate a new one each time it 
expires.  Note, however,
-    that you must be careful with access tokens, since anyone who has the 
token can
-    access your account as that client.
-    </p>
-    {% for app in model %}
-    <table class="registered_app">
-        <tr><th>Name:</th><td>{{app.client.name}}</td></tr>
-        <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="client_secret"><th>Client 
Secret:</th><td>{{app.client.client_secret}}</td></tr>
-        <tr class="redirect_url"><th>Redirect 
URL:</th><td>{{app.client.redirect_uris[0] if app.client.redirect_uris else 
''}}</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.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>
-        {% endif %}
-
-        <tr class="controls">
-            <td colspan="2">
-                <form method="POST" action="do_client_action" 
class="client_action">
-                    <input type="hidden" name="_id" 
value="{{app.client.client_id}}"/>
-                    <input id="deregister" type="submit" name="deregister" 
value="Deregister Client"/>
-                    <input id="revoke" type="submit" name="revoke" 
value="Revoke Access"/>
-                    {{lib.csrf_token()}}
-                </form>
-            </td>
-        </tr>
-    </table>
-    {% endfor %}
-
-    <h2>Register New Application</h2>
-    {{ c.form.display() }}
-{% endblock %}
diff --git a/Allura/allura/templates/oauth2_authorize_ok.html 
b/Allura/allura/templates/oauth2_authorize_ok.html
deleted file mode 100644
index 6334b35c9..000000000
--- a/Allura/allura/templates/oauth2_authorize_ok.html
+++ /dev/null
@@ -1,35 +0,0 @@
-{#-
-       Licensed to the Apache Software Foundation (ASF) under one
-       or more contributor license agreements.  See the NOTICE file
-       distributed with this work for additional information
-       regarding copyright ownership.  The ASF licenses this file
-       to you under the Apache License, Version 2.0 (the
-       "License"); you may not use this file except in compliance
-       with the License.  You may obtain a copy of the License at
-
-         http://www.apache.org/licenses/LICENSE-2.0
-
-       Unless required by applicable law or agreed to in writing,
-       software distributed under the License is distributed on an
-       "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-       KIND, either express or implied.  See the License for the
-       specific language governing permissions and limitations
-       under the License.
--#}
-{% set hide_left_bar = True %}
-{% extends g.theme.master %}
-
-{% block title %} Third-party client authorized. {% endblock %}
-
-{% block header %}Third-party client authorized.{% endblock %}
-
-{% block content %}
-<p>You have authorized {{ client.name }} access to your account.  If you wish
-  to revoke this access at any time, please visit
-  <a href="/auth/preferences">user preferences</a>
-  and click 'revoke access'.</p>
-<p>You can use the following authorization code to request an access token 
from /rest/oauth2/token.</p>
-<h2>Authorization Code: {{ authorization_code }}</h2>
-<p>Please be aware that the authorization code will be valid for 10 
minutes.</p>
-<a href="/auth/preferences/">Return to preferences</a>
-{% endblock %}
diff --git a/Allura/allura/templates/oauth_applications.html 
b/Allura/allura/templates/oauth_applications.html
index 9b22cfc68..d2e44c55c 100644
--- a/Allura/allura/templates/oauth_applications.html
+++ b/Allura/allura/templates/oauth_applications.html
@@ -78,6 +78,8 @@
 
 {% block content %}
     {{ super() }}
+    <h5>For direct API use, <a href="#oauth_applications">create an 
application client</a> and generate a bearer token</h5>
+    <br/>
     <h2>Authorized Applications</h2>
     <p>
         These are applications you have authorized to act on your behalf.
@@ -148,13 +150,12 @@
                     <input type="submit" value="Delete all access tokens"/>
                     {{lib.csrf_token()}}
                 </form>
-                {#
-                <form method="POST" action="generate_access_token2" 
class="generate_access_token">
-                    <input type="hidden" name="_id" 
value="{{consumer_token._id}}"/>
+                <form method="POST" action="generate_bearer_token" 
class="generate_bearer_token">
+                    <input type="hidden" name="client_id" 
value="{{client.client_id}}"/>
                     <input type="submit" value="Generate Bearer Token"/>
                     {{lib.csrf_token()}}
                 </form>
-                 #}
+
             </td>
         </tr>
     </table>
@@ -197,7 +198,7 @@
     {% endif %}
 
     {% if h.asbool(config.get('auth.oauth2.enabled', False)) %}
-    <div class="grid-24" style="margin-left:0">
+    <div id="oauth_applications" class="grid-24" style="margin-left:0">
     <h2>Register New OAuth2 Application</h2>
     {{ c.form2.display() }}
     </div>
diff --git a/Allura/allura/tests/functional/test_auth.py 
b/Allura/allura/tests/functional/test_auth.py
index 214caaee2..be33727bb 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -33,6 +33,7 @@ from tg import config, expose
 from mock import patch, Mock
 import mock
 import pytest
+import webtest
 from tg import tmpl_context as c, app_globals as g
 
 from allura.tests import TestController
@@ -2067,6 +2068,79 @@ class TestOAuth(TestController):
 
 
 class TestOAuth2(TestController):
+    @pytest.fixture
+    def mock_client(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            client_secret='98765',
+            user_id=user._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+        ThreadLocalODMSession.flush_all()
+
+    @pytest.fixture
+    def mock_credentials(self):
+        return dict(client_id='client_12345', 
redirect_uri='https://localhost/', response_type='code', state=None)
+
+    @pytest.fixture
+    def mock_valid_token(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2AccessToken(
+            client_id='client_12345',
+            access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8',
+            refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP',
+            scopes=[],
+            expires_at=datetime.utcnow() + timedelta(hours=1),
+            user_id=user._id
+        )
+        ThreadLocalODMSession.flush_all()
+
+    @pytest.fixture
+    def mock_expired_token(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2AccessToken(
+            client_id='client_12345',
+            access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8',
+            refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP',
+            scopes=[],
+            expires_at=datetime.utcnow() - timedelta(days=1),
+            user_id=user._id
+        )
+        ThreadLocalODMSession.flush_all()
+
+    @pytest.fixture
+    def mock_valid_authorization_code(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2AuthorizationCode(
+            client_id='client_12345',
+            authorization_code='XToC3m9whoA304HMMKOOUF678n6s6r',
+            scopes=[],
+            redirect_uri='https://localhost/',
+            user_id=user._id,
+            expires_at=datetime.utcnow() + timedelta(minutes=10)
+        )
+        ThreadLocalODMSession.flush_all()
+
+    @pytest.fixture
+    def mock_expired_authorization_code(self):
+        user = M.User.by_username('test-admin')
+        M.OAuth2AuthorizationCode(
+            client_id='client_12345',
+            authorization_code='XToC3m9whoA304HMMKOOUF678n6s6r',
+            scopes=[],
+            redirect_uri='https://localhost/',
+            user_id=user._id,
+            expires_at=datetime.utcnow() - timedelta(minutes=10),
+            # Code verifier: 
iX7BOwXKgX2PH1diMIN_RQDiczkFTYAyzc-bhUVC3iCD3P2l_fYQYCORcLKbw2uGjGTX9UUBTAIqcjAdf3QRCQ
+            code_challenge='CqIDFfrLC7TTUzeCUdHryYMCUdiXjQsqX6qHgQtsFOQ',
+            code_challenge_method='S256'
+        )
+        ThreadLocalODMSession.flush_all()
+
     @mock.patch.dict(config, {'auth.oauth2.enabled': True})
     def test_register_deregister_client(self):
         # register
@@ -2098,7 +2172,7 @@ class TestOAuth2(TestController):
             redirect_uris=['https://localhost/']
         )
         ThreadLocalODMSession.flush_all()
-        r = self.app.get('/rest/oauth2/authorize', params={'client_id': 
'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+        r = self.app.get('/auth/oauth2/authorize', params={'client_id': 
'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
         assert 'testoauth2' in r.text
         assert 'client_12345' in r.text
 
@@ -2115,7 +2189,7 @@ class TestOAuth2(TestController):
             redirect_uris=['https://localhost/']
         )
         ThreadLocalODMSession.flush_all()
-        r = self.app.get('/rest/oauth2/authorize', params={'client_id': 
'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
+        r = self.app.get('/auth/oauth2/authorize', params={'client_id': 
'client_12345', 'response_type': 'code', 'redirect_uri': 'https://localhost/'})
         r = r.forms[0].submit('no')
         assert M.OAuth2AuthorizationCode.query.get(client_id='client_12345') 
is None
 
@@ -2139,7 +2213,7 @@ class TestOAuth2(TestController):
         r.mustcontain(no='testoauth2')
 
         # 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/'})
+        r = self.app.get('/auth/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.forms[0].submit('yes')
 
@@ -2160,7 +2234,7 @@ class TestOAuth2(TestController):
         self.app.post_json('/rest/oauth2/token', oauth2_params, 
extra_environ={'username': '*anonymous'})
         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
+        assert t.access_token is not None and t.refresh_token is not None and 
not t.is_bearer
 
         r = self.app.get('/auth/oauth/')
         r.mustcontain('testoauth2')
@@ -2247,6 +2321,122 @@ class TestOAuth2(TestController):
         r.mustcontain(no='testoauth2')
 
 
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_pkce(self, mock_client, mock_credentials):
+        code_verifier = 
'QkatVHgTq_cZj8tTKWPIe78fXpoeszhVq6kLIUxJj8g9tMmfi0XV4dfZHQBXwOiWsLihJotfrOGKR4nZSXA4mA'
+        code_challenge = 'BxGpJVKt_l6Srlq3uXPfpxge3TxtxetcWhGXq2958yU'
+        code_challenge_method = 'S256' # Must be uppercase
+
+        # Authorize the app by sending the code challenge and code challenge 
method as qs param
+        params = dict(client_id='client_12345', response_type='code', 
redirect_uri='https://localhost/', code_challenge=code_challenge,
+                        code_challenge_method=code_challenge_method)
+        r = self.app.get('/auth/oauth2/authorize', params=params)
+
+        # Authorize app
+        r.forms[0].submit('yes')
+
+        # Get the authorization code
+        ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+        assert ac is not None
+
+        # Exchange the authorization code for an access token. It should fail 
if you do not provide the code verifier
+        body = dict(client_id='client_12345', client_secret='98765', 
code=ac.authorization_code, grant_type='authorization_code', 
redirect_uri='https://localhost/')
+
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+
+        assert 'Code verifier required' in str(ex.value)
+
+        # Now provide the code verifier and it should pass
+        body.update(code_verifier=code_verifier)
+        r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+        assert r.status_int == 200
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_refresh_token(self, mock_client, mock_valid_token):
+        token = M.OAuth2AccessToken.query.get(client_id='client_12345')
+
+        body = dict(client_id='client_12345', client_secret='98765', 
grant_type='refresh_token', refresh_token=token.refresh_token)
+        r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+        assert r.status_int == 200
+        assert r.json['access_token'] != token.access_token
+        assert r.json['refresh_token'] != token.refresh_token
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_invalid_refresh_token(self, mock_client, mock_valid_token):
+        body = dict(client_id='client_12345', client_secret='98765', 
grant_type='refresh_token', refresh_token='invalid_token')
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+
+        assert 'invalid_grant' in str(ex.value)
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_access_token_with_expired_code(self, mock_client, 
mock_expired_authorization_code):
+        c = M.OAuth2ClientApp.query.get(client_id='client_12345')
+        ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+
+        body = dict(client_id=c.client_id, client_secret=c.client_secret, 
grant_type='authorization_code', code=ac.authorization_code,
+                        redirect_uri=c.redirect_uris[0])
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+
+        assert 'invalid_grant' in str(ex.value)
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_access_token_with_invalid_redirect_uri(self, mock_client, 
mock_valid_authorization_code):
+        c = M.OAuth2ClientApp.query.get(client_id='client_12345')
+        ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+
+        body = dict(client_id=c.client_id, client_secret=c.client_secret, 
grant_type='authorization_code', code=ac.authorization_code,
+                        redirect_uri='https://invalid.com')
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+
+        assert 'Mismatching redirect URI' in str(ex.value)
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_access_token_with_invalid_credentials(self, mock_client, 
mock_valid_authorization_code):
+        c = M.OAuth2ClientApp.query.get(client_id='client_12345')
+        ac = M.OAuth2AuthorizationCode.query.get(client_id='client_12345')
+
+        # First test passing an invalid client id
+        body = dict(client_id='invalid_client_id', 
client_secret=c.client_secret, grant_type='authorization_code', 
code=ac.authorization_code,
+                        redirect_uri=c.redirect_uris[0])
+
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+
+        assert 'invalid_client' in str(ex.value)
+
+        # Now test passing an invalid client secret
+        body = dict(client_id=c.client_id, client_secret='invalid_secret', 
grant_type='authorization_code', code=ac.authorization_code,
+                        redirect_uri=c.redirect_uris[0])
+
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.app.post_json('/rest/oauth2/token', body, 
extra_environ={'username': '*anonymous'})
+
+        assert 'invalid_client' in str(ex.value)
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_authorization_code_no_duplicates(self, mock_client):
+        c = M.OAuth2ClientApp.query.get(client_id='client_12345')
+
+        params = dict(client_id=c.client_id, response_type='code', 
redirect_uri=c.redirect_uris[0])
+        r = self.app.get('/auth/oauth2/authorize', params=params)
+        r.forms[0].submit('yes')
+
+        # Find all authorization codes and validate there's only one record
+        ac = 
M.OAuth2AuthorizationCode.query.find(dict(client_id=c.client_id)).all()
+        assert len(ac) == 1
+
+        # Now try to authorize again and validate there's still only one record
+        r = self.app.get('/auth/oauth2/authorize', params=params)
+        r.forms[0].submit('yes')
+
+        ac = 
M.OAuth2AuthorizationCode.query.find(dict(client_id=c.client_id)).all()
+        assert len(ac) == 1
+
+
 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 5368b871b..d3b896b8e 100644
--- a/Allura/allura/tests/functional/test_rest.py
+++ b/Allura/allura/tests/functional/test_rest.py
@@ -17,11 +17,14 @@
 
 
 import json
+from datetime import datetime, timedelta
 
 import tg
 from bson import ObjectId
 from tg import app_globals as g
 import mock
+import pytest
+import webtest
 from ming.odm import ThreadLocalODMSession
 from tg import config
 
@@ -419,6 +422,50 @@ class TestRestHome(TestRestApiBase):
         r = self.app.get('/rest/notification')
         assert r.json == {}
 
+    @td.with_wiki
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_oauth2_token_authentication(self):
+        # Create a valid access token
+        user = M.User.by_username('test-admin')
+        token = M.OAuth2AccessToken(
+            client_id='client_12345',
+            access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8',
+            refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP',
+            scopes=[],
+            expires_at=datetime.utcnow() + timedelta(days=1),
+            user_id=user._id
+        )
+        ThreadLocalODMSession.flush_all()
+
+        self.set_api_token(token)
+        r = self.api_get('/rest/p/test/wiki/Home')
+        params = dict(text=r.json['text'])
+        r = self.api_post('/rest/p/test/wiki/Home', params=params)
+        assert r.status_int == 200
+
+    @td.with_wiki
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_oauth2_expired_token_authentication(self):
+        # Create an expired access token
+        user = M.User.by_username('test-admin')
+        token = M.OAuth2AccessToken(
+            client_id='client_12345',
+            access_token='ULy1kNFFSCYCscfcoS2oF5Z8i61Mx8',
+            refresh_token='QuoBOJ6CWJlrg0CSXMKDyTezQQHPOP',
+            scopes=[],
+            expires_at=datetime.utcnow() - timedelta(days=1),
+            user_id=user._id
+        )
+        ThreadLocalODMSession.flush_all()
+
+        self.set_api_token(token)
+        with pytest.raises(webtest.app.AppError) as ex:
+            r = self.api_get('/rest/p/test/wiki/Home')
+            params = dict(text=r.json['text'])
+            r = self.api_post('/rest/p/test/wiki/Home', params=params)
+            
+        assert '401 Unauthorized' in str(ex.value)
+
 
 class TestRestNbhdAddProject(TestRestApiBase):
 
diff --git a/Allura/docs/api-rest/docs.md b/Allura/docs/api-rest/docs.md
index 324f9f421..35db1ae64 100755
--- a/Allura/docs/api-rest/docs.md
+++ b/Allura/docs/api-rest/docs.md
@@ -167,7 +167,7 @@ The following example demonstrates the authorization 
workflow and how to generat
     # Set up your client credentials
     client_id = 'YOUR_CLIENT_ID'
     client_secret = 'YOUR_CLIENT_SECRET'
-    authorization_base_url = 
'https://forge-allura.apache.org/rest/oauth2/authorize'
+    authorization_base_url = 
'https://forge-allura.apache.org/auth/oauth2/authorize'
     access_token_url = 'https://forge-allura.apache.org/rest/oauth2/token'
     redirect_uri = 'https://forge-allura.apache.org/page'  # Your registered 
redirect URI
 
@@ -259,7 +259,7 @@ You can use the following example to generate a valid code 
verifier and code cha
 
 Having generated the codes, you would need to send the code challenge along 
with the challenge method (in this case S256) as part of the query string in 
the authorization url, for example:
 
-    
https://forge-allura.apache.org/rest/oauth2/authorize?client_id=8dca182d3e6fe0cb76b8&response_type=code&code_challenge=G6wIRjEZlvhLsVS0exbID3o4ppUBsjxUBNtRVL8StXo&code_challenge_method=S256
+    
https://forge-allura.apache.org/auth/oauth2/authorize?client_id=8dca182d3e6fe0cb76b8&response_type=code&code_challenge=G6wIRjEZlvhLsVS0exbID3o4ppUBsjxUBNtRVL8StXo&code_challenge_method=S256
 
 
 Afterwards, when you request an access token, you must provide the code 
verifier that derived the code challenge as part of the request's body, 
otherwise the token request validation will fail:
diff --git a/Allura/docs/api-rest/securitySchemes.yaml 
b/Allura/docs/api-rest/securitySchemes.yaml
index f52b052ad..60a56f4e2 100755
--- a/Allura/docs/api-rest/securitySchemes.yaml
+++ b/Allura/docs/api-rest/securitySchemes.yaml
@@ -41,7 +41,7 @@
     description: |
         OAuth 2.0 may also be used to authenticate API requests.
 
-        First authorize your application at 
https://forge-allura.apache.org/rest/oauth2/authorize with following
+        First authorize your application at 
https://forge-allura.apache.org/auth/oauth2/authorize with following
         query string parameters:
           - response_type=code
           - client_id=YOUR_CLIENT_ID
@@ -67,10 +67,10 @@
         `Authorization: Bearer MY_BEARER_TOKEN``
     type: OAuth 2.0
     settings:
-      authorizationUri: https://forge-allura.apache.org/rest/oauth2/authorize
+      authorizationUri: https://forge-allura.apache.org/auth/oauth2/authorize
       accessTokenUri: https://forge-allura.apache.org/rest/oauth2/token
       authorizationGrants:
         - authorization_code
         - refresh_token
       scopes:
-        - 
+        -
diff --git a/AlluraTest/alluratest/controller.py 
b/AlluraTest/alluratest/controller.py
index 782fcc44a..55ef5d94e 100644
--- a/AlluraTest/alluratest/controller.py
+++ b/AlluraTest/alluratest/controller.py
@@ -264,7 +264,12 @@ class TestRestApiBase(TestController):
         if not isinstance(params, str):
             params = variabledecode.variable_encode(params, 
add_repetitions=False)
 
-        token = self.token(user).api_key
+        bearer_token = self.token(user)
+        try:
+            token = bearer_token.api_key
+        except AttributeError:
+            token = bearer_token.access_token
+
         headers = {
             'Authorization': str(f'Bearer {token}')
         }
diff --git a/scripts/wiki-copy.py b/scripts/wiki-copy.py
index ee04011c6..bba2e5ad9 100644
--- a/scripts/wiki-copy.py
+++ b/scripts/wiki-copy.py
@@ -119,7 +119,7 @@ def make_oauth2_client(base_url) -> requests.Session:
     cp = ConfigParser()
     cp.read(config_file)
 
-    AUTHORIZE_URL = base_url + '/rest/oauth2/authorize'
+    AUTHORIZE_URL = base_url + '/auth/oauth2/authorize'
     ACCESS_TOKEN_URL = base_url + '/rest/oauth2/token'
 
     client_id = option(cp, base_url, 'oauth2_client_id',


Reply via email to