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

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

commit 84d9051e762557333b36e1fdc36bd94c57e4b020
Author: Dave Brondsema <dbronds...@slashdotmedia.com>
AuthorDate: Wed May 8 16:37:13 2024 -0400

    [#7272] combine oauth1 and oauth2 settings onto existing page, improve tests
---
 Allura/allura/controllers/auth.py               | 182 +++++++++++++++---------
 Allura/allura/controllers/rest.py               |   2 +-
 Allura/allura/lib/widgets/oauth_widgets.py      |  31 +++-
 Allura/allura/model/oauth.py                    |   9 ++
 Allura/allura/templates/oauth_applications.html |  82 +++++++++--
 Allura/allura/tests/functional/test_auth.py     | 140 ++++++++++--------
 Allura/development.ini                          |   3 +
 pytest.ini                                      |   2 +
 8 files changed, 313 insertions(+), 138 deletions(-)

diff --git a/Allura/allura/controllers/auth.py 
b/Allura/allura/controllers/auth.py
index 06ac5183c..2ab770a69 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -14,6 +14,7 @@
 #       KIND, either express or implied.  See the License for the
 #       specific language governing permissions and limitations
 #       under the License.
+from __future__ import annotations
 
 import logging
 import os
@@ -75,7 +76,7 @@ class F:
     subscription_form = SubscriptionForm()
     registration_form = forms.RegistrationForm(action='/auth/save_new')
     oauth_application_form = OAuthApplicationForm(action='register')
-    oauth2_application_form = OAuth2ApplicationForm(action='register')
+    oauth2_application_form = OAuth2ApplicationForm(action='register2')
     oauth_revocation_form = OAuthRevocationForm(
         action='/auth/preferences/revoke_oauth')
     change_personal_data_form = forms.PersonalDataForm()
@@ -115,9 +116,6 @@ class AuthController(BaseController):
         self.subscriptions = SubscriptionsController()
         self.oauth = OAuthController()
 
-        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()
 
@@ -1329,13 +1327,52 @@ class OAuthController(BaseController):
     @expose('jinja:allura:templates/oauth_applications.html')
     def index(self, **kw):
         c.form = F.oauth_application_form
+        c.form2 = F.oauth2_application_form
+
         consumer_tokens = M.OAuthConsumerToken.for_user(c.user)
-        access_tokens = M.OAuthAccessToken.for_user(c.user)
+        access_tokens: list[dict] = []  # simple dict format to work for both 
OAuth1 and OAuth2 tokens
+        for oauth1_tok in M.OAuthAccessToken.for_user(c.user):
+            access_tokens.append({
+                'type': 1,
+                'app': oauth1_tok.consumer_token,
+                'is_bearer': oauth1_tok.is_bearer,
+                'api_key': oauth1_tok.api_key,
+                'last_access': oauth1_tok.last_access,
+                '_id': oauth1_tok._id,
+            })
+        if asbool(config.get('auth.oauth2.enabled', False)):
+            oauth2_client_apps = M.OAuth2ClientApp.for_user(c.user)
+            for oauth2_tok in M.OAuth2AccessToken.query.find({'user_id': 
c.user._id}):
+                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,
+                    '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)
+                if client_app not in (app['app'] for app in access_tokens):
+                    access_tokens.append({
+                        'type': '2authcode',
+                        'app': client_app,
+                        'is_bearer': False,
+                        'api_key': None,
+                        'last_access': None,
+                        '_id': oauth2_auth._id,
+                    })
+        else:
+            oauth2_client_apps = []
+
         provider = plugin.AuthenticationProvider.get(request)
         return dict(
             menu=provider.account_navigation(),
             consumer_tokens=consumer_tokens,
             access_tokens=access_tokens,
+            oauth2_client_apps=oauth2_client_apps,
         )
 
     @expose()
@@ -1347,6 +1384,24 @@ class OAuthController(BaseController):
         flash('OAuth Application registered')
         redirect('.')
 
+    @expose()
+    @require_post()
+    @validate(F.oauth2_application_form, error_handler=index)
+    def register2(self, application_name=None, application_description=None, 
**kw):
+        if not asbool(config.get('auth.oauth2.enabled', False)):
+            raise wexc.HTTPNotFound
+
+        redirect_urls = [
+            v for k, v in kw.items()
+            if k.startswith('redirect_url_') and v
+        ]
+        M.OAuth2ClientApp(name=application_name,
+                          description=application_description,
+                          redirect_uris=redirect_urls,
+                          user_id=c.user._id)
+        flash('OAuth2 Client registered')
+        redirect('.')
+
     @expose()
     @require_post()
     def deregister(self, _id=None):
@@ -1363,6 +1418,37 @@ class OAuthController(BaseController):
         flash('Application deleted')
         redirect('.')
 
+    def _check_perm_and_revoke_all2(self, client_id: str):
+        """
+        Revokes the authorization code and access tokens for a given client 
for all its users
+        """
+        if not asbool(config.get('auth.oauth2.enabled', False)):
+            raise wexc.HTTPNotFound
+
+        client = M.OAuth2ClientApp.query.get(client_id=client_id)
+        if client is None or client.user_id != c.user._id:
+            flash('Invalid client ID', 'error')
+            redirect('.')
+
+        M.OAuth2AuthorizationCode.query.remove({'client_id': client_id})
+        M.OAuth2AccessToken.query.remove({'client_id': client_id})
+        return client
+
+    @expose()
+    @require_post()
+    def deregister2(self, client_id):
+        client = self._check_perm_and_revoke_all2(client_id)
+        client.delete()
+        flash('Client deleted and all access tokens revoked.')
+        redirect('.')
+
+    @expose()
+    @require_post()
+    def revoke_all_access_tokens2(self, client_id):
+        self._check_perm_and_revoke_all2(client_id)
+        flash('Access tokens revoked.')
+        redirect('.')
+
     @expose()
     @require_post()
     def generate_access_token(self, _id):
@@ -1395,83 +1481,51 @@ class OAuthController(BaseController):
         )
         redirect('.')
 
-    @expose()
-    @require_post()
-    def revoke_access_token(self, _id):
-        access_token = M.OAuthAccessToken.query.get(_id=bson.ObjectId(_id))
+    def _check_revoke_perm(self, access_token):
         if access_token is None:
             flash('Invalid token ID', 'error')
             redirect('.')
         if access_token.user_id != c.user._id:
             flash('Invalid token ID', 'error')
             redirect('.')
+
+    @expose()
+    @require_post()
+    def revoke_access_token(self, _id):
+        access_token = M.OAuthAccessToken.query.get(_id=bson.ObjectId(_id))
+        self._check_revoke_perm(access_token)
         access_token.delete()
-        flash('Token revoked')
+        flash('Authorization revoked')
         redirect('.')
 
-
-class OAuth2Controller(BaseController):
-    def _check_security(self):
-        require_authenticated()
-
-    # Revokes the authorization code and access tokens for a given client and 
user
-    def _revoke_user_tokens(self, client_id, user_id):
-        M.OAuth2AuthorizationCode.query.remove({'client_id': client_id, 
'user_id': user_id})
-        M.OAuth2AccessToken.query.remove({'client_id': client_id, 'user_id': 
user_id})
-
-    # Revokes the authorization code and access tokens for a given client and 
all its users
-    def _revoke_all(self, client_id):
-        M.OAuth2AuthorizationCode.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.OAuth2ClientApp.for_user(c.user)
-        model = []
-
-        for client in clients:
-            authorization = 
M.OAuth2AuthorizationCode.query.get(client_id=client.client_id, 
user_id=c.user._id)
-            token = M.OAuth2AccessToken.query.get(client_id=client.client_id, 
user_id=c.user._id)
-            model.append(dict(client=client, authorization=authorization, 
token=token))
-
-        return dict(
-            menu=provider.account_navigation(),
-            model=model
-        )
-
     @expose()
     @require_post()
-    @validate(F.oauth2_application_form, error_handler=index)
-    def register(self, application_name=None, application_description=None, 
redirect_url=None, **kw):
-        M.OAuth2ClientApp(name=application_name,
-                          description=application_description,
-                          redirect_uris=[redirect_url] if redirect_url else [],
-                          user_id=c.user._id)
-        flash('Oauth2 Client registered')
+    def revoke_access_token2(self, _id):
+        if not asbool(config.get('auth.oauth2.enabled', False)):
+            raise wexc.HTTPNotFound
+
+        tok = M.OAuth2AccessToken.query.get(_id=bson.ObjectId(_id))
+        self._check_revoke_perm(tok)
+        # also any auth codes associated
+        M.OAuth2AuthorizationCode.query.remove({'client_id': tok.client_id, 
'user_id': c.user._id})
+        tok.delete()
+        flash('Authorization revoked')
         redirect('.')
 
     @expose()
     @require_post()
-    def do_client_action(self, _id=None, deregister=None, revoke=None):
-        client = M.OAuth2ClientApp.query.get(client_id=_id)
-        if client is None or client.user_id != c.user._id:
-            flash('Invalid client ID', 'error')
-            redirect('.')
-
-        if deregister:
-            self._revoke_all(_id)
-            client.delete()
-            flash('Client deleted and access tokens revoked.')
+    def revoke_access_token2authcode(self, _id):
+        if not asbool(config.get('auth.oauth2.enabled', False)):
+            raise wexc.HTTPNotFound
 
-        if revoke:
-            self._revoke_user_tokens(_id, c.user._id)
-            flash('Access tokens revoked.')
+        auth = M.OAuth2AuthorizationCode.query.get(_id=bson.ObjectId(_id))
+        self._check_revoke_perm(auth)
+        # also any access tokens associated
+        M.OAuth2AccessToken.query.remove({'client_id': auth.client_id, 
'user_id': c.user._id})
+        auth.delete()
+        flash('Authorization revoked')
         redirect('.')
 
-
 class DisableAccountController(BaseController):
 
     def _check_security(self):
diff --git a/Allura/allura/controllers/rest.py 
b/Allura/allura/controllers/rest.py
index f0b8f8448..173585ea7 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -555,7 +555,7 @@ class Oauth2Negotiator:
 
         if no:
             flash(f'{client.name} NOT AUTHORIZED', 'error')
-            redirect('/auth/oauth2/')
+            redirect('/auth/oauth/')
 
         credentials = json.loads(request.params['credentials'])
         headers, body, status = self.server.create_authorization_response(
diff --git a/Allura/allura/lib/widgets/oauth_widgets.py 
b/Allura/allura/lib/widgets/oauth_widgets.py
index 201bc35ce..5c7105c1f 100644
--- a/Allura/allura/lib/widgets/oauth_widgets.py
+++ b/Allura/allura/lib/widgets/oauth_widgets.py
@@ -17,6 +17,7 @@
 
 import ew as ew_core
 import ew.jinja2_ew as ew
+from formencode import validators as fev
 
 from allura.lib import validators as V
 
@@ -30,7 +31,11 @@ class OAuthApplicationForm(ForgeForm):
 
     class fields(ew_core.NameList):
         application_name = ew.TextField(label='Application Name',
-                                        
validator=V.UniqueOAuthApplicationName())
+                                        
validator=V.UniqueOAuthApplicationName(),
+                                        attrs=dict(
+                                            required=True,
+                                        ),
+                                        )
         application_description = AutoResizeTextarea(
             label='Application Description')
 
@@ -49,6 +54,26 @@ class OAuth2ApplicationForm(ForgeForm):
 
     class fields(ew_core.NameList):
         application_name = ew.TextField(label='Application Name',
-                                        
validator=V.UniqueOAuthApplicationName())
+                                        
validator=V.UnicodeString(not_empty=True),
+                                        attrs=dict(
+                                            required=True,
+                                        ),
+                                        )
         application_description = AutoResizeTextarea(label='Application 
Description')
-        redirect_url = ew.TextField(label='Redirect URL')
+
+        # SortableRepeatedField would be nice to use (and ignore sorting) so 
you can add many dynamically,
+        # but couldn't get it to work easily
+        redirect_url_1 = ew.TextField(
+            label='Redirect URL(s)',
+            validator=fev.URL(not_empty=True),
+            attrs=dict(type='url', style='min-width:25em', required=True),
+        )
+        redirect_url_2 = ew.TextField(
+            validator=fev.URL(),
+            attrs=dict(type='url', style='min-width:25em; margin-left: 
162px;'),  # match grid-4 label width
+        )
+        redirect_url_3 = ew.TextField(
+            validator=fev.URL(),
+            attrs=dict(type='url', style='min-width:25em; margin-left: 
162px;'),  # match grid-4 label width
+        )
+
diff --git a/Allura/allura/model/oauth.py b/Allura/allura/model/oauth.py
index 0e9b752c8..b8b21d8a3 100644
--- a/Allura/allura/model/oauth.py
+++ b/Allura/allura/model/oauth.py
@@ -159,6 +159,9 @@ class OAuth2ClientApp(MappedClass):
         session = main_orm_session
         name = 'oauth2_client_app'
         unique_indexes = [('client_id', 'user_id')]
+        indexes = [
+            ('user_id'),
+        ]
 
     query: 'Query[OAuth2ClientApp]'
 
@@ -192,6 +195,9 @@ class OAuth2AuthorizationCode(MappedClass):
         session = main_orm_session
         name = 'oauth2_authorization_code'
         unique_indexes = [('authorization_code', 'client_id', 'user_id')]
+        indexes = [
+            ('user_id'),
+        ]
 
     query: 'Query[OAuth2AuthorizationCode]'
 
@@ -214,6 +220,9 @@ class OAuth2AccessToken(MappedClass):
         session = main_orm_session
         name = 'oauth2_access_token'
         unique_indexes = [('access_token', 'client_id', 'user_id')]
+        indexes = [
+            ('user_id'),
+        ]
 
     query: 'Query[OAuth2AccessToken]'
 
diff --git a/Allura/allura/templates/oauth_applications.html 
b/Allura/allura/templates/oauth_applications.html
index c788e7722..9b22cfc68 100644
--- a/Allura/allura/templates/oauth_applications.html
+++ b/Allura/allura/templates/oauth_applications.html
@@ -57,6 +57,14 @@
             }
         });
 
+        $('.revoke_access_tokens').submit(function(e) {
+            var ok = confirm('Revoke all current access tokens?')
+            if(!ok) {
+                e.preventDefault();
+                return false;
+            }
+        });
+
         $('.revoke_access_token').submit(function(e) {
             var ok = confirm('Revoke access?')
             if(!ok) {
@@ -77,13 +85,13 @@
         no longer using an application listed here, you should revoke its
         access.
     </p>
-    {% for access_token in access_tokens %}
+    {% for access_token in access_tokens %}{# both oauth1 & oauth2 #}
     <table class="authorized_app">
         <tr class="name">
-            <th>Name:</th><td>{{access_token.consumer_token.name}}</td>
+            <th>Name:</th><td>{{access_token.app.name}}</td>
         </tr>
         <tr class="description">
-            
<th>Description:</th><td>{{access_token.consumer_token.description_html }}</td>
+            <th>Description:</th><td>{{access_token.app.description_html 
}}</td>
         </tr>
         {% if access_token.is_bearer %}
         <tr class="bearer_token">
@@ -91,11 +99,11 @@
         </tr>
         {% endif %}
          <tr>
-            <th>Last Access:</th><td>{%  if access_token.last_access %} {{ 
access_token.last_access.strftime('%a %b %d, %Y %I:%M %p UTC')  }} {%  endif 
%}</td>
+            <th>Last Access:</th><td>{% if access_token.last_access %} {{ 
access_token.last_access.strftime('%a %b %d, %Y %I:%M %p UTC')  }} {%  endif 
%}</td>
         </tr>
         <tr class="controls">
             <td colspan="2">
-                <form method="POST" action="revoke_access_token" 
class="revoke_access_token">
+                <form method="POST" action="revoke_access_token{{ '' if 
access_token.type == 1 else access_token.type}}" class="revoke_access_token">
                     <input type="hidden" name="_id" 
value="{{access_token._id}}"/>
                     <input type="submit" value="Revoke"/>
                     {{lib.csrf_token()}}
@@ -103,9 +111,58 @@
             </td>
         </tr>
     </table>
+    {% else %}
+        No applications have been authorized to access your account.
+    {% endfor %}
+
+    {% if oauth2_client_apps %}
+    <h2>My OAuth2 Applications</h2>
+    <p>
+    These are the client applications you have registered.  They can request 
authorization
+    for a user using the Client ID and Client Secret via OAuth negotiation.
+    See the <a href="{{ config['doc.url.api'] }}">API documentation</a> for 
more information.
+    </p>
+    {% for client in oauth2_client_apps %}
+    <table>
+        <tr><th>Type:</th><td>OAuth2</td></tr>
+        <tr><th>Name:</th><td>{{client.name}}</td></tr>
+        <tr 
class="description"><th>Description:</th><td>{{client.description_html 
}}</td></tr>
+        <tr><th>Client ID:</th><td>{{client.client_id}}</td></tr>
+        <tr><th>Client Secret:</th><td>{{client.client_secret}}</td></tr>
+        <tr><th>Redirect URL:</th><td>
+            {% for uri in client.redirect_uris %}
+                {{ uri }}<br/>
+            {% else %}
+                None!
+            {% endfor %}
+        </td></tr>
+        <tr class="controls">
+            <td colspan="2">
+                <form method="POST" action="deregister2" 
class="deregister_consumer_token">
+                    <input type="hidden" name="client_id" 
value="{{client.client_id}}"/>
+                    <input type="submit" value="Delete App"/>
+                    {{lib.csrf_token()}}
+                </form>
+                <form method="POST" action="revoke_all_access_tokens2" 
class="revoke_access_tokens">
+                    <input type="hidden" name="client_id" 
value="{{client.client_id}}"/>
+                    <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}}"/>
+                    <input type="submit" value="Generate Bearer Token"/>
+                    {{lib.csrf_token()}}
+                </form>
+                 #}
+            </td>
+        </tr>
+    </table>
     {% endfor %}
+    {% endif %}
 
-    <h2>My Applications</h2>
+    {% if consumer_tokens %}
+    <h2>My OAuth1 Applications</h2>
     <p>
     These are the applications you have registered.  They can request 
authorization
     for a user using the Consumer Key and Consumer Secret via OAuth 
negotiation.
@@ -116,6 +173,7 @@
     </p>
     {% for consumer_token in consumer_tokens %}
     <table class="registered_app">
+        <tr><th>Type:</th><td>OAuth1</td></tr>
         <tr><th>Name:</th><td>{{consumer_token.name}}</td></tr>
         <tr 
class="description"><th>Description:</th><td>{{consumer_token.description_html 
}}</td></tr>
         <tr class="consumer_key"><th>Consumer 
Key:</th><td>{{consumer_token.api_key}}</td></tr>
@@ -124,7 +182,7 @@
             <td colspan="2">
                 <form method="POST" action="deregister" 
class="deregister_consumer_token">
                     <input type="hidden" name="_id" 
value="{{consumer_token._id}}"/>
-                    <input type="submit" value="Deregister"/>
+                    <input type="submit" value="Delete App"/>
                     {{lib.csrf_token()}}
                 </form>
                 <form method="POST" action="generate_access_token" 
class="generate_access_token">
@@ -136,7 +194,15 @@
         </tr>
     </table>
     {% endfor %}
+    {% endif %}
+
+    {% if h.asbool(config.get('auth.oauth2.enabled', False)) %}
+    <div class="grid-24" style="margin-left:0">
+    <h2>Register New OAuth2 Application</h2>
+    {{ c.form2.display() }}
+    </div>
+    {% endif %}
 
-    <h2>Register New Application</h2>
+    <h2>Register New OAuth1 Application</h2>
     {{ c.form.display() }}
 {% endblock %}
diff --git a/Allura/allura/tests/functional/test_auth.py 
b/Allura/allura/tests/functional/test_auth.py
index a5e69e2d9..214caaee2 100644
--- a/Allura/allura/tests/functional/test_auth.py
+++ b/Allura/allura/tests/functional/test_auth.py
@@ -2070,18 +2070,19 @@ 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()
+        r = self.app.get('/auth/oauth/')
+        form = [f for f in r.forms.values() if f.action == 'register2'][0]
+        form['application_name'] = 'testoauth2'
+        form['application_description'] = 'Oauth2 Test'
+        form['redirect_url_1'] = 'https://example.com/'
+        r = form.submit().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/')
+        form = [f for f in r.forms.values() if f.action == 'deregister2'][0]
+        form.submit()
+        r = self.app.get('/auth/oauth/')
         assert 'testoauth2' not in r
 
     @mock.patch.dict(config, {'auth.oauth2.enabled': True})
@@ -2089,6 +2090,7 @@ class TestOAuth2(TestController):
         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',
@@ -2105,6 +2107,7 @@ class TestOAuth2(TestController):
         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',
@@ -2112,44 +2115,19 @@ class TestOAuth2(TestController):
             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',
-            user_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/'})
+        r = r.forms[0].submit('no')
+        assert M.OAuth2AuthorizationCode.query.get(client_id='client_12345') 
is None
 
-        # The submit authorization for the authorization code to be created
-        mock_credentials = dict(client_id='client_12345', 
redirect_uri='https://localhost/', response_type='code', state=None)
-        r = self.app.post('/rest/oauth2/do_authorize',
-                          params={'yes': '1', 'client_id': 'client_12345', 
'response_type': 'code',
-                                  'redirect_uri': 'https://localhost/', 
'credentials': json.dumps(mock_credentials)})
-
-        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')
+    def test_authorize_and_create_access_token(self):
+        # client owned by someone other than the test-admin user that 
self.app.get/post use
+        app_owner = M.User.by_username('test-user')
         M.OAuth2ClientApp(
             client_id='client_12345',
             client_secret='98765',
-            user_id=user._id,
+            user_id=app_owner._id,
             name='testoauth2',
             description='test client',
             response_type='code',
@@ -2157,20 +2135,19 @@ class TestOAuth2(TestController):
         )
         ThreadLocalODMSession.flush_all()
 
+        r = self.app.get('/auth/oauth/')
+        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/'})
-
         # The submit authorization for the authorization code to be created
-        mock_credentials = dict(client_id='client_12345', 
redirect_uri='https://localhost/', response_type='code', state=None)
-        r = self.app.post('/rest/oauth2/do_authorize',
-                          params={'yes': '1', 'client_id': 'client_12345', 
'response_type': 'code',
-                                  'redirect_uri': 'https://localhost/', 
'credentials': json.dumps(mock_credentials)})
+        r.forms[0].submit('yes')
 
         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
+        r = self.app.get('/auth/oauth/')
+        r.mustcontain('testoauth2')
 
         # Create the authorization token
         oauth2_params = dict(
@@ -2180,30 +2157,69 @@ class TestOAuth2(TestController):
             grant_type='authorization_code',
             redirect_uri='https://localhost/'
         )
-        r = self.app.post_json('/rest/oauth2/token', oauth2_params)
+        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
 
-        r = self.app.get('/auth/oauth2/')
-        assert 'Access Token:' in r
-        assert 'Refresh Token:' in r
+        r = self.app.get('/auth/oauth/')
+        r.mustcontain('testoauth2')
+
 
     @mock.patch.dict(config, {'auth.oauth2.enabled': True})
-    def test_revoke_tokens(self):
-        user = M.User.by_username('test-admin')
+    def test_revoke_auth_code(self):
+        # only the auth code is present, and it gets revoked
+        app_owner = M.User.by_username('test-user')
         M.OAuth2ClientApp(
             client_id='client_12345',
+            client_secret='98765',
+            user_id=app_owner._id,
+            name='testoauth2',
+            description='test client',
+            response_type='code',
+            redirect_uris=['https://localhost/']
+        )
+
+        user = M.User.by_username('test-admin')
+        M.OAuth2AuthorizationCode(
+            client_id='client_12345',
+            authorization_code='authcode_12345',
+            expires_at=datetime.utcnow() + timedelta(minutes=10),
             user_id=user._id,
+        )
+
+        ThreadLocalODMSession.flush_all()
+
+        r = self.app.get('/auth/oauth/')
+        r.mustcontain('testoauth2')
+
+        form = [f for f in r.forms.values() if f.action == 
'revoke_access_token2authcode'][0]
+        form.submit()
+
+        assert not M.OAuth2AuthorizationCode.query.get(user_id=user._id)
+
+        r = self.app.get('/auth/oauth/')
+        r.mustcontain(no='testoauth2')
+
+
+    @mock.patch.dict(config, {'auth.oauth2.enabled': True})
+    def test_revoke_access_token(self):
+        # both auth code and access token are present, both get revoked
+        app_owner = M.User.by_username('test-user')
+        M.OAuth2ClientApp(
+            client_id='client_12345',
+            client_secret='98765',
+            user_id=app_owner._id,
             name='testoauth2',
             description='test client',
             response_type='code',
             redirect_uris=['https://localhost/']
         )
 
+        user = M.User.by_username('test-admin')
         M.OAuth2AuthorizationCode(
             client_id='client_12345',
-            authotization_code='authcode_12345',
+            authorization_code='authcode_12345',
             expires_at=datetime.utcnow() + timedelta(minutes=10),
             user_id=user._id,
         )
@@ -2218,17 +2234,17 @@ class TestOAuth2(TestController):
 
         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 = self.app.get('/auth/oauth/')
+        r.mustcontain('testoauth2')
+
+        form = [f for f in r.forms.values() if f.action == 
'revoke_access_token2'][0]
+        form.submit()
 
-        r.forms[0].submit('revoke')
+        assert not M.OAuth2AuthorizationCode.query.get(user_id=user._id)
+        assert not M.OAuth2AccessToken.query.get(user_id=user._id)
 
-        r = self.app.get('/auth/oauth2/')
-        assert 'testoauth2' in r
-        assert 'Authorization Code:' not in r
-        assert 'Access Token:' not in r
+        r = self.app.get('/auth/oauth/')
+        r.mustcontain(no='testoauth2')
 
 
 class TestOAuthRequestToken(TestController):
diff --git a/Allura/development.ini b/Allura/development.ini
index 424538d3f..25384c647 100644
--- a/Allura/development.ini
+++ b/Allura/development.ini
@@ -497,6 +497,9 @@ disable_entry_points.allura.theme.override = responsive
 ; they'll appear on the import forms
 ;doc.url.importers.GitHub = http://...
 
+; If you have specific API documentation for your site, you can change this
+doc.url.api = 
https://forge-allura.apache.org/docs/getting_started/administration.html#public-api
+
 ; List of oauth API keys permitted to use special forum import API
 ; (should be converted to newer importer system)
 ;oauth.can_import_forum = api-key-1234, fa832r0fdsafd, f23f80sdf32fd
diff --git a/pytest.ini b/pytest.ini
index 1d26fdcf3..ffc5fc09b 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -35,6 +35,8 @@ filterwarnings =
     ignore:Deprecated call to 
`pkg_resources.declare_namespace:DeprecationWarning:pkg_resources
     ignore:pkg_resources is deprecated as an 
API:DeprecationWarning:tg.util.files
     ignore:pkg_resources is deprecated as an API:DeprecationWarning:formencode
+    # supporting py3.8 still then can revert 
https://sourceforge.net/p/activitystream/code/ci/c0884668ac0f4445acb423edb25d18b7bd368be7/
+    ignore:SelectableGroups dict interface is deprecated. Use 
select.:DeprecationWarning:activitystream
 
     # py3.12
     ignore::DeprecationWarning:smtpd

Reply via email to