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 6dcad1466 [#7272] Update docs and wiki-copy script for OAuth2
6dcad1466 is described below

commit 6dcad1466a9aa3edb02c9b8c88b40ea040ad4651
Author: Carlos Cruz <carlos.c...@slashdotmedia.com>
AuthorDate: Mon May 13 21:46:37 2024 +0000

    [#7272] Update docs and wiki-copy script for OAuth2
---
 Allura/allura/controllers/auth.py         |   2 +-
 Allura/allura/controllers/rest.py         |  22 ++--
 Allura/docs/api-rest/api.raml             |   6 +-
 Allura/docs/api-rest/docs.md              | 173 +++++++++++++++++++++++++-----
 Allura/docs/api-rest/securitySchemes.yaml |  37 +++++++
 scripts/wiki-copy.py                      |  88 ++++++++++++++-
 6 files changed, 290 insertions(+), 38 deletions(-)

diff --git a/Allura/allura/controllers/auth.py 
b/Allura/allura/controllers/auth.py
index 61bfd7d75..06ac5183c 100644
--- a/Allura/allura/controllers/auth.py
+++ b/Allura/allura/controllers/auth.py
@@ -1448,7 +1448,7 @@ class OAuth2Controller(BaseController):
     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],
+                          redirect_uris=[redirect_url] if redirect_url else [],
                           user_id=c.user._id)
         flash('Oauth2 Client registered')
         redirect('.')
diff --git a/Allura/allura/controllers/rest.py 
b/Allura/allura/controllers/rest.py
index bb0496a8a..f0b8f8448 100644
--- a/Allura/allura/controllers/rest.py
+++ b/Allura/allura/controllers/rest.py
@@ -275,8 +275,12 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
     def get_default_scopes(self, client_id: str, request: 
oauthlib.common.Request, *args, **kwargs):
         return []
 
+    def get_original_scopes(self, refresh_token: str, request: 
oauthlib.common.Request, *args, **kwargs) -> list[str]:
+        return None
+
     def get_default_redirect_uri(self, client_id: str, request: 
oauthlib.common.Request, *args, **kwargs) -> str:
-        return request.uri
+        client = M.OAuth2ClientApp.query.get(client_id=client_id)
+        return client.redirect_uris[0] if client.redirect_uris else None
 
     def is_pkce_required(self, client_id: str, request: 
oauthlib.common.Request) -> bool:
         return False
@@ -304,6 +308,9 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
         access_token = M.OAuth2AccessToken.query.get(access_token=token)
         return access_token.expires_at >= datetime.utcnow() if access_token 
else 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
+
     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.
         # If a redirect uri was provided when the authorization code was 
created, it must match the redirect uri provided here.
@@ -331,11 +338,15 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
         log.info(f'Saving new authorization code for client: {client_id}')
 
     def save_bearer_token(self, token, request: oauthlib.common.Request, 
*args, **kwargs) -> object:
-        authorization_code = 
M.OAuth2AuthorizationCode.query.get(client_id=request.client_id, 
authorization_code=request.code)
-        current_token = 
M.OAuth2AccessToken.query.get(client_id=request.client_id, 
user_id=authorization_code.user_id)
+        if request.grant_type == 'authorization_code':
+            user_id = 
M.OAuth2AuthorizationCode.query.get(client_id=request.client_id, 
authorization_code=request.code).user_id
+        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)
 
         if current_token:
-            M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 
'user_id': c.user._id})
+            M.OAuth2AccessToken.query.remove({'client_id': request.client_id, 
'user_id': user_id})
 
         bearer_token = M.OAuth2AccessToken(
             client_id=request.client_id,
@@ -343,7 +354,7 @@ class Oauth2Validator(oauthlib.oauth2.RequestValidator):
             access_token=token.get('access_token'),
             refresh_token=token.get('refresh_token'),
             expires_at=datetime.utcnow() + 
timedelta(seconds=token.get('expires_in')),
-            user_id=authorization_code.user_id
+            user_id=user_id
         )
 
         session(bearer_token).flush()
@@ -572,7 +583,6 @@ class Oauth2Negotiator:
         headers, body, status = 
self.server.create_token_response(uri=request.url, http_method=request.method, 
body=request_body, headers=request.headers)
         return body
 
-
 def rest_has_access(obj, user, perm):
     """
     Helper function that encapsulates common functionality for has_access API
diff --git a/Allura/docs/api-rest/api.raml b/Allura/docs/api-rest/api.raml
index dd655cfd6..44072067c 100755
--- a/Allura/docs/api-rest/api.raml
+++ b/Allura/docs/api-rest/api.raml
@@ -24,7 +24,7 @@
 title: Apache Allura
 version: 1
 baseUri: https://{domain}/rest
-securedBy: [null, oauth_1_0]
+securedBy: [null, oauth_1_0, oauth_2_0]
 
 resourceTypes: !include resourceTypes.yaml
 traits: !include traits.yaml
@@ -63,6 +63,10 @@ documentation:
     description: |
       See separate docs section for authenticating with the OAuth 1.0 APIs
 
+/oauth2:
+    description: |
+      See separate docs section for authenticating with the OAuth 2.0 APIs
+
 /{neighborhood}:
     description: |
       Neighborhoods are groups of logically related projects, which have the 
same default options.
diff --git a/Allura/docs/api-rest/docs.md b/Allura/docs/api-rest/docs.md
index 886514719..324f9f421 100755
--- a/Allura/docs/api-rest/docs.md
+++ b/Allura/docs/api-rest/docs.md
@@ -19,7 +19,7 @@
 
 # Basic API architecture
 
-All url endpoints are prefixed with /rest/ and the path to the project and 
tool.  
+All url endpoints are prefixed with /rest/ and the path to the project and 
tool.
 
 For example, in order to access a wiki installed in the 'test' project with 
the mount point 'docs' the API endpoint would be /rest/p/test/docs.
 
@@ -60,9 +60,9 @@ Python code example to create a new ticket:
 
     import requests
     from pprint import pprint
-    
+
     BEARER_TOKEN = '<bearer token from oauth page>'
-    
+
     r = 
requests.post('https://forge-allura.apache.org/rest/p/test-project/tickets/new',
 params={
             'access_token': BEARER_TOKEN,
             'ticket_form.summary': 'Test ticket',
@@ -92,76 +92,195 @@ If you want your application to be able to use the API on 
behalf of another user
     REQUEST_TOKEN_URL = 
'https://forge-allura.apache.org/rest/oauth/request_token'
     AUTHORIZE_URL = 'https://forge-allura.apache.org/rest/oauth/authorize'
     ACCESS_TOKEN_URL = 
'https://forge-allura.apache.org/rest/oauth/access_token'
-    
+
     oauth = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET, 
callback_uri='oob')
-    
-    # Step 1: Get a request token. This is a temporary token that is used for 
-    # having the user authorize an access token and to sign the request to 
obtain 
+
+    # Step 1: Get a request token. This is a temporary token that is used for
+    # having the user authorize an access token and to sign the request to 
obtain
     # said access token.
-    
+
     request_token = oauth.fetch_request_token(REQUEST_TOKEN_URL)
-    
+
     # these are intermediate tokens and not needed later
     # print("Request Token:")
     # print("    - oauth_token        = %s" % request_token['oauth_token'])
     # print("    - oauth_token_secret = %s" % 
request_token['oauth_token_secret'])
     # print()
-    
-    # Step 2: Redirect to the provider. Since this is a CLI script we do not 
+
+    # Step 2: Redirect to the provider. Since this is a CLI script we do not
     # redirect. In a web application you would redirect the user to the URL
     # below, specifying the additional parameter oauth_callback=<your callback 
URL>.
-    
+
     webbrowser.open(oauth.authorization_url(AUTHORIZE_URL, 
request_token['oauth_token']))
-    
-    # Since we didn't specify a callback, the user must now enter the PIN 
displayed in 
-    # their browser.  If you had specified a callback URL, it would have been 
called with 
+
+    # Since we didn't specify a callback, the user must now enter the PIN 
displayed in
+    # their browser.  If you had specified a callback URL, it would have been 
called with
     # oauth_token and oauth_verifier parameters, used below in obtaining an 
access token.
     oauth_verifier = input('What is the PIN? ')
-    
+
     # Step 3: Once the consumer has redirected the user back to the 
oauth_callback
-    # URL you can request the access token the user has approved. You use the 
+    # URL you can request the access token the user has approved. You use the
     # request token to sign this request. After this is done you throw away the
-    # request token and use the access token returned. You should store this 
+    # request token and use the access token returned. You should store this
     # access token somewhere safe, like a database, for future use.
     access_token = oauth.fetch_access_token(ACCESS_TOKEN_URL, oauth_verifier)
-    
+
     print("Access Token:")
     print("    - oauth_token        = %s" % access_token['oauth_token'])
     print("    - oauth_token_secret = %s" % access_token['oauth_token_secret'])
     print()
-    print("You may now access protected resources using the access tokens 
above.") 
+    print("You may now access protected resources using the access tokens 
above.")
     print()
 
 
 You can then use your access token with the REST API.  For instance script to 
create a wiki page might look like this:
 
     from requests_oauthlib import OAuth1Session
-    
+
     PROJECT='test'
-    
+
     CONSUMER_KEY='<consumer key from app registration>'
     CONSUMER_SECRET='<consumer secret from app registration>'
-    
+
     ACCESS_KEY='<access key from previous script>'
     ACCESS_SECRET='<access secret from previous script>'
-    
+
     URL_BASE='https://forge-allura.apache.org/rest/'
-    
+
     oauth = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET,
                           resource_owner_key=ACCESS_KEY, 
resource_owner_secret=ACCESS_SECRET)
-    
+
     response = oauth.post(URL_BASE + 'p/' + PROJECT + '/wiki/TestPage',
                           data=dict(text='This is a test page'))
     response.raise_for_status()
     print("Done.  Response was:")
     print(response)
 
+### OAuth2 Authorization
+
+Another option for authorizing your apps is to use the OAuth2 workflow. This 
is accomplished by authorizing the application which generates an 
`authorization_code` that can be later exchanged for an `access_token`
+
+The following example demonstrates the authorization workflow and how to 
generate an access token to authenticate your apps
+
+    from requests_oauthlib import OAuth2Session
+
+    # 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'
+    access_token_url = 'https://forge-allura.apache.org/rest/oauth2/token'
+    redirect_uri = 'https://forge-allura.apache.org/page'  # Your registered 
redirect URI
+
+    # Create an OAuth2 session
+    oauth2 = OAuth2Session(client_id, redirect_uri=redirect_uri)
+
+    # Step 1: Prompt the user to navigate to the authorization URL
+    authorization_url, state = oauth2.authorization_url(authorization_base_url)
+
+    print('Please go to this URL to authorize the app:', authorization_url)
+
+    # Step 2: Obtain the authorization code (you can find it in the 'code' URL 
parameter)
+    # In real use cases, you might implement a small web server to capture this
+    authorization_code = input('Paste authorization code here: ')
+
+    # Step 3: Exchange the authorization code for an access token
+    token = oauth2.fetch_token(access_token_url,
+                            code=authorization_code,
+                            client_secret=client_secret,
+                            include_client_id=True)
+
+    # Print the access and refresh tokens for verification (or use it to 
request user data)
+    # If your access token expires, you can request a new one using the 
refresh token
+    print(f"Access Token: {token.get('access_token')}")
+    print(f"Refresh Token: {token.get('refresh_token')}")
+
+    # Step 4: Use the access token to make authenticated requests
+    response = oauth2.get('https://forge-allura.apache.org/user')
+    print('User data:', response.json())
+
+### Refreshing Access Tokens
+
+A new access token can be requested once it expires. The following example 
demonstrates how can the refresh token obtained in the previous code sample be 
used to generate a new access token:
+
+    from requests_oauthlib import OAuth2Session
+
+    # Set up your client credentials
+    client_id = 'YOUR_CLIENT_ID'
+    client_secret = 'YOUR_CLIENT_SECRET'
+    refresh_token = 'YOUR_REFRESH_TOKEN'
+    access_token = 'YOUR_ACCESS_TOKEN'
+    access_token_url = 'https://forge-allura.apache.org/rest/oauth2/token'
+
+    # Step 1: Create an OAuth2 session by also passing token information
+    token = dict(access_token=access_token, token_type='Bearer', 
refresh_token=refresh_token)
+    oauth2 = OAuth2Session(client_id=client_id, token=token)
+
+    # Step 2: Request for a new token
+    extra = dict(client_id=client_id, client_secret=client_secret)
+    refreshed_token = oauth2.refresh_token(access_token_url, **extra)
+
+    # You can inspect the response object to get the new access and refresh 
tokens
+    print(f"Access Token: {token.get('access_token')}")
+    print(f"Refresh Token: {token.get('refresh_token')}")
+
+### PKCE support
+
+PKCE (Proof Key for Code Exchange) is an extension to the authorization code 
flow to prevent CSRF and authorization code injection attacks. It mitigates the 
risk of the authorization code being intercepted by a malicious entity during 
the exchange from the authorization endpoint to the token endpoint.
+
+To make use of this security extension, you must generate a string known as a 
"code verifier", which is a random string using the characters A-Z, a-z, 0-9 
and the special characters -._~ and it should be between 43 and 128 characters 
long.
+
+Once the string has been created, perform a SHA256 hash on it and encode the 
resulting value as a Base-
+
+You can use the following example to generate a valid code verifier and code 
challenge:
+
+    import hashlib
+    import base64
+    import os
+
+
+    # Generate a code verifier (random string)
+    def generate_code_verifier(length=64):
+        return base64.urlsafe_b64encode(
+          os.urandom(length)).decode('utf-8').rstrip('=')
+
+
+    # Generate a code challenge (SHA-256)
+    def generate_code_challenge(verifier):
+        digest = hashlib.sha256(verifier.encode('utf-8')).digest()
+        return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
+
+
+    code_verifier = generate_code_verifier()
+    code_challenge = generate_code_challenge(code_verifier)
+
+    # The code challenge should be sent in the initial authorization request.
+    print("Code Verifier:", code_verifier)
+    print("Code Challenge:", code_challenge)
+
+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
+
+
+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:
+
+    POST https://forge-allura.apache.org/rest/oauth2/token
+
+    {
+        "client_id": "8dca182d3e6fe0cb76b8",
+        "client_secret": 
"1c6a2d99db80223590dd12cc32dfdb8a0cc2e9a38620e05c16076b2872110688b9c1b17db63bb7c3",
+        "code": "Gvw53xmSBFZYBy0xdawm0qSX0cqhHs",
+        "code_verifier": 
"aEyhTs4BfWjZ7g5HT0o7Hu24p6Qw6TxotdX8_G20NN9J1lXIfSnNr3b6jhOUZe5ZWkP5ADCEzlWABUHSPXslgQ",
+        "grant_type": "authorization_code"
+    }
+
+
 
 # Permission checks
 
 The `has_access` API can be used to run permission checks. It is available on 
a neighborhood, project and tool level.
 
-It is only available to users that have 'admin' permission for corresponding 
neighborhood/project/tool.  
+It is only available to users that have 'admin' permission for corresponding 
neighborhood/project/tool.
 It requires `user` and `perm` parameters and will return JSON dict with 
`result` key, which contains boolean value, indicating if given `user` has 
`perm` permission to the neighborhood/project/tool.
 
 
diff --git a/Allura/docs/api-rest/securitySchemes.yaml 
b/Allura/docs/api-rest/securitySchemes.yaml
index 194b5f71a..f52b052ad 100755
--- a/Allura/docs/api-rest/securitySchemes.yaml
+++ b/Allura/docs/api-rest/securitySchemes.yaml
@@ -37,3 +37,40 @@
       authorizationUri: https://forge-allura.apache.org/rest/oauth/authorize
       tokenCredentialsUri: 
https://forge-allura.apache.org/rest/oauth/access_token
 
+- oauth_2_0:
+    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
+        query string parameters:
+          - response_type=code
+          - client_id=YOUR_CLIENT_ID
+          - redirect_uri=YOUR_REDIRECT_URI
+
+        For PKCE support send these additional parameters
+          - code_challenge=YOUR_CODE_CHALLENGE
+          - code_challenge_method=S256
+
+        An authorization code will be generated which can be exchanged for an 
access token at https://forge-allura.apache.org/rest/oauth2/token
+        with the following parameters:
+          - grant_type=authorization_code
+          - code=YOUR_AUTHORIZATION_CODE
+          - client_id=YOUR_CLIENT_ID
+          - client_secret=YOUR_CLIENT_SECRET
+          - redirect_uri=YOUR_REDIRECT_URI
+
+        For PKCE support send these additional parameters
+          - code_verifier=YOUR_CODE_VERIFIER
+
+        Use the access token in an HTTP header like:
+
+        `Authorization: Bearer MY_BEARER_TOKEN``
+    type: OAuth 2.0
+    settings:
+      authorizationUri: https://forge-allura.apache.org/rest/oauth2/authorize
+      accessTokenUri: https://forge-allura.apache.org/rest/oauth2/token
+      authorizationGrants:
+        - authorization_code
+        - refresh_token
+      scopes:
+        - 
diff --git a/scripts/wiki-copy.py b/scripts/wiki-copy.py
index c023684b0..ee04011c6 100644
--- a/scripts/wiki-copy.py
+++ b/scripts/wiki-copy.py
@@ -19,12 +19,13 @@
 
 import os
 import sys
-from optparse import OptionParser
+from optparse import OptionParser, OptionValueError
 from configparser import ConfigParser, NoOptionError
+from datetime import datetime, timedelta
 import webbrowser
 
 import requests
-from requests_oauthlib import OAuth1Session
+from requests_oauthlib import OAuth1Session, OAuth2Session
 
 
 def main():
@@ -36,10 +37,13 @@ def main():
                   help='URL of wiki API to copy to like 
http://toserver.com/rest/p/test/wiki/')
     op.add_option('-D', '--debug', action='store_true',
                   dest='debug', default=False)
+    op.add_option('-O', '--oauth', type='int', dest='oauth_version', default=1,
+                  help='OAuth version to use for authentication. Defaults to 
OAuth v1.',
+                  action='callback', callback=validate_oauth_version)
     (options, args) = op.parse_args(sys.argv[1:])
 
     base_url = options.to_wiki.split('/rest/')[0]
-    oauth_client = make_oauth_client(base_url)
+    oauth_client = make_oauth2_client(base_url) if options.oauth_version == 2 
else make_oauth_client(base_url)
 
     wiki_json = requests.get(options.from_wiki, timeout=30).json()['pages']
     for p in wiki_json:
@@ -107,6 +111,77 @@ def make_oauth_client(base_url) -> requests.Session:
     return oauthSess
 
 
+def make_oauth2_client(base_url) -> requests.Session:
+    """
+    Build an oauth2 client with which callers can query Allura.
+    """
+    config_file = os.path.join(os.environ['HOME'], '.allurarc')
+    cp = ConfigParser()
+    cp.read(config_file)
+
+    AUTHORIZE_URL = base_url + '/rest/oauth2/authorize'
+    ACCESS_TOKEN_URL = base_url + '/rest/oauth2/token'
+
+    client_id = option(cp, base_url, 'oauth2_client_id',
+                       'Forge API OAuth2 Client App ID (%s/auth/oauth/): ' % 
base_url)
+    client_secret = option(cp, base_url, 'oauth2_client_secret',
+                           'Forge API Oauth2 Client App Secret: ')
+
+    def token_saver(token):
+        token_expires = datetime.utcnow() + 
timedelta(seconds=token.get('expires_in'))
+        cp.set(base_url, 'oauth2_expires_in', 
str(int(token_expires.timestamp())))
+        cp.set(base_url, 'oauth2_access_token', token['access_token'])
+        cp.set(base_url, 'oauth2_refresh_token', token.get('refresh_token', 
''))
+        cp.write(open(config_file, 'w'))
+        print(f'Saving refreshed OAuth2 access token in {config_file} for 
later re-use')
+
+    try:
+        access_token = cp.get(base_url, 'oauth2_access_token')
+        refresh_token = cp.get(base_url, 'oauth2_refresh_token')
+        expires_in = cp.get(base_url, 'oauth2_expires_in')
+    except NoOptionError:
+        oauth2_session = OAuth2Session(client_id)
+        authorization_url, state = 
oauth2_session.authorization_url(AUTHORIZE_URL)
+        if isinstance(webbrowser.get(), webbrowser.GenericBrowser):
+            print('Go to %s' % authorization_url)
+        else:
+            webbrowser.open(authorization_url)
+
+        authorization_code = input("What is the authorization code? (You can 
copy it from the 'code' parameter in the URL you were redirected to) ")
+        response = oauth2_session.fetch_token(
+            ACCESS_TOKEN_URL,
+            code=authorization_code,
+            client_secret=client_secret,
+            include_client_id=True)
+
+        # We get the expiration date from oauthlib in seconds so we need to 
determine the actual date
+        # and save its Unix timestamp representation
+        token_expires = datetime.utcnow() + 
timedelta(seconds=response.get('expires_in'))
+        cp.set(base_url, 'oauth2_expires_in', 
str(int(token_expires.timestamp())))
+        cp.set(base_url, 'oauth2_access_token', response.get('access_token'))
+        cp.set(base_url, 'oauth2_refresh_token', response.get('refresh_token'))
+
+        # save access token for later use
+        cp.write(open(config_file, 'w'))
+        print(f'Saving OAuth2 access token in {config_file} for later re-use')
+        print()
+    else:
+        print(f'Saved access token: {access_token}')
+
+        # requests-oauthlib expects the expiration time as seconds so we use 
the saved timestamp to calculate
+        # the differece. If that difference is a negative number it means the 
token is already expired and a new one
+        # will be automatically generated.
+        date_diff = datetime.utcfromtimestamp(int(expires_in)) - 
datetime.utcnow()
+        token_expires = int(date_diff.total_seconds())
+        oauth2_session = OAuth2Session(client_id=client_id,
+                          token=dict(access_token=access_token, 
token_type='Bearer', refresh_token=refresh_token, expires_in=token_expires),  # 
noqa: S106
+                          auto_refresh_url=ACCESS_TOKEN_URL,
+                          auto_refresh_kwargs=dict(client_id=client_id, 
client_secret=client_secret),
+                          token_updater=token_saver)
+
+    return oauth2_session
+
+
 def option(cp, section, key, prompt=None):
     if not cp.has_section(section):
         cp.add_section(section)
@@ -118,5 +193,12 @@ def option(cp, section, key, prompt=None):
     return value
 
 
+def validate_oauth_version(option, opt_str, value, parser):
+    if value not in (1, 2):
+        raise OptionValueError(f'Option {opt_str} requires a value of 1 or 2')
+
+    setattr(parser.values, option.dest, value)
+
+
 if __name__ == '__main__':
     main()

Reply via email to