azure-devops-cli-extension icon indicating copy to clipboard operation
azure-devops-cli-extension copied to clipboard

[Feature Request] CLI for creating Personal Access Tokens

Open lukeschlather opened this issue 4 years ago • 4 comments

Is your feature request related to a problem? Please describe. Some sort of CLI replacement for the UI at https://dev.azure.com/strivr/_usersSettings/tokens

Describe the solution you'd like

az devops pat create az devops pat delete az devops pat list

lukeschlather avatar Dec 19 '19 15:12 lukeschlather

@lukeschlather Azure-devops does not support creating of Personal Access tokens using an API. Only list functionality is supported.

atbagga avatar Dec 24 '19 12:12 atbagga

Was this done or not? I can't seem to find any documentation or anything about this.

gravufo avatar Sep 15 '21 22:09 gravufo

There is an API now. It is rather cumbersome. Also I'm not sure it's possible to integrate the API into the CLI because the API doesn't support PAT authentication. I cobbled this together from the docs. Doesn't actually need a secret, just an application client_id that has been set to Public access in the Azure Portal.

import atexit
import datetime
import json
import logging
import os
import pprint
import sys

import msal
import requests

# SCOPES = [
#     # This scope is not strictly speaking necessary.
#     "User.ReadBasic.All",
#     "499b84ac-1321-427f-aa17-267ca6975798/.default"
# ]

# apparently Azure Devops team is too good for human-readable scopes.
SCOPES = ["499b84ac-1321-427f-aa17-267ca6975798/.default"]
ENDPOINT = "https://graph.microsoft.com/v1.0/users"


# Optional logging
# logging.basicConfig(level=logging.DEBUG)  # Enable DEBUG log for entire script
# logging.getLogger("msal").setLevel(logging.INFO)  # Optionally disable MSAL DEBUG logs

class AuthError(RuntimeError):
    pass

class MicrosoftGraphClient:
    # https://portal.azure.com/ => Azure Active Directory => Sidebar App Registrations
    # Azure Devops PAT generator
    # Secret file should be json with 2 fields: tenant_id, client_id
    def __init__(self, collection, app_secret_file="app_secret.json"):
        self.pat_endpoint = f'https://vssps.dev.azure.com/{collection}/_apis/Tokens/Pats?api-version=6.1-preview'
        
        with open(app_secret_file) as raw_secret:
            secrets = json.loads(raw_secret.read())

            self.authority = "https://login.microsoftonline.com/" + secrets["tenant_id"]
            self.client_id = secrets["client_id"]
            
        self.cache = msal.SerializableTokenCache()
        if os.path.exists("my_cache.bin"):
            self.cache.deserialize(open("my_cache.bin", "r").read())
            atexit.register(lambda:
                            open("my_cache.bin", "w").write(self.cache.serialize())
                            # The following optional line persists only when state changed
            if self.cache.has_state_changed else None
            )

        # Create a preferably long-lived app instance which maintains a token cache.
        client_app = msal.PublicClientApplication(
            self.client_id,
            #client_credential=self.client_secret,
            authority=self.authority,
            token_cache=self.cache
        )

        # The pattern to acquire a token looks like this.
        result = None

        # Note: If your device-flow app does not have any interactive ability, you can
        #   completely skip the following cache part. But here we demonstrate it anyway.
        # We now check the cache to see if we have some end users signed in before.
        accounts = client_app.get_accounts()
        if accounts:
            logging.info("Account(s) exists in cache, probably with token too. Let's try.")
            print("Pick the account you want to use to proceed:")
            for a in accounts:
                print(a["username"])
                # Assuming the end user chose this one
            chosen = accounts[0]

            # Now let's try to find a token in cache for this account
            result = client_app.acquire_token_silent(SCOPES, account=chosen)

        if not result:
            logging.info("No suitable token exists in cache. Let's get a new one from AAD.")

            # this doesn't work... says it needs a client secret... but you can't use one.
            flow = client_app.initiate_device_flow(scopes=SCOPES)
            if "user_code" not in flow:
                raise ValueError(
                    "Fail to create device flow. Err: %s" % json.dumps(flow, indent=4))

            print(flow["message"])
            sys.stdout.flush()  # Some terminal needs this to ensure the message is shown

            # Ideally you should wait here, in order to save some unnecessary polling
            input("Press Enter after signing in from another device to proceed, CTRL+C to abort.")

            result = client_app.acquire_token_by_device_flow(flow)  # By default it will block

        if "access_token" not in result:
            print(result.get("error"))
            print(result.get("error_description"))
            print(result.get("correlation_id"))  # You may need this when reporting a bug
            raise AuthError(result.get("error"))
        
        self.access_token = result['access_token']
        
    def make_graph_call(self, endpoint):
        graph_data = requests.get(
            endpoint,
            headers={'Authorization': 'Bearer ' + self.access_token}
        ).json()
        
        print("Graph API call result: %s" % json.dumps(graph_data, indent=2))

    def get_pats(self):
        pats = requests.get(
            self.pat_endpoint,
            headers={'Authorization': 'Bearer ' + self.access_token}
        ).json()

        return pats

    def create_pat(self, body):
        # two years is the max in the UI. A little less so we don't have to
        # worry about timezone and leap year shenanigans.
        max_validity = ( datetime.datetime.today() + datetime.timedelta(days=(365*4)) ).isoformat()

        defaults = {
            "validTo": max_validity,
            "allOrgs": False
        }
        
        body = {
            **defaults,
            **body,
        }

        pats = requests.post(
            self.pat_endpoint,
            json=body,
            headers={'Authorization': 'Bearer ' + self.access_token}
        ).json()

        return pat
    
    def delete_pat(self, authorization_id):
        pat = requests.delete(
            self.pat_endpoint,
            params={
                'authorizationId': authorization_id
            },
            headers={'Authorization': 'Bearer ' + self.access_token}
        )

        return pat
    

client = MicrosoftGraphClient(app_secret_file="/secrets/pat-generator-client-secret/app_secret.json")

all_pats = client.get_pats()
pprint.pprint(all_pats)

lukeschlather avatar Sep 15 '21 22:09 lukeschlather

this is how you can create a PAT

1. Update these values

azdoOrganizationName="OrgName" patDisplayName="PAT"

2. Run az login if not using cloud shell

3. Run the below command

az rest --method post --uri "https://vssps.dev.azure.com/$azdoOrganizationName/_apis/Tokens/Pats?api-version=6.1-preview" --resource "https://management.core.windows.net/" --body '{ "displayName": "$patDisplayName" }' --headers Content-Type=application/json

4. Validate you token

az rest --method get --uri "https://vssps.dev.azure.com/$azdoOrganizationName/_apis/Tokens/Pats?api-version=6.1-preview" --resource "https://management.core.windows.net/" --headers Content-Type=application/json

JynLeazy avatar Feb 12 '24 23:02 JynLeazy