How do I sign my JWT requests for the REST interface?

I’m trying to use the REST interface, but I can’t get the JSON web token to work correctly. I keep getting 401 errors back.

Here’s my code:

ENDPOINT = 'https://testtablet.atlassian.net'
URL_PREFIX = '/rest/api/latest'

def get_token(url):
    method = 'GET'
    path = URL_PREFIX + url

    token = atlassian_jwt.encode_token(method, url, **SHELF['install_json'])
    headers = {'Authorization': 'JWT {}'.format(token)}
    return requests.get(ENDPOINT + path, params={}, headers=headers)

And I call this with:

get_token("/issue/TEST-2")

What am I doing wrong?

1 Like

Can you tell me which library atlassian_jwt.encode_token is from?

Sure. It’s at https://bitbucket.org/atlassian/atlassian-jwt-py

Try:

tenant_info = **SHELF['install_json'] # explicitly expanded below for better debugging
token = atlassian_jwt.encode_token(method, path, tenant_info.clientKey, tenant_info.sharedSecret)

The documentation for the method reads:

url (string): URL to sign. Must be relative to the host base URL found
              in the tenant information.

Hence, you should send the full URL relative the JIRA’s baseUrl, in other words, including the /rest/api/latest.

I’m not 100% sure that will be enough. This Python library grew up for Bitbucket Cloud, so there are some aspects that do not look fully compliant with JIRA. For example, the JWT payload sends an additional aud claim. I suppose it should be ignored, but I’ve not tested with it with JIRA. Furthermore, this library calculates QSH without any query parameters. That would fail for any request with a query string.

1 Like

My code is now:

ENDPOINT = 'https://testtablet.atlassian.net'
URL_PREFIX = '/rest/api/latest'

def get_token(url):
    method = 'GET'
    path = URL_PREFIX + url

    tenant_info = SHELF['install_json']
    token = atlassian_jwt.encode_token(method, path, tenant_info['clientKey'], tenant_info['sharedSecret'])
    headers = {'Authorization': 'JWT {}'.format(token)}
    return requests.get(ENDPOINT + path, params={}, headers=headers)

Running this:

get_token("/issue/TEST-2")

Produces a 401 error.

Hi there, I’ve been working on a python connect implementation on the side I’m not entirely sure what’s going wrong with that approach - possibly the aud field - but I can show you how I’ve done it in the past. I use a combination of the atlassian-jwt library and another called requests-jwt:

import time
import urllib.parse

import requests
import requests_jwt
from atlassian_jwt.url_utils import hash_url as generate_qsh


def qsh(base_url):
    base_path = urllib.parse.urlparse(base_url).path

    def qsh_generator(request):
        relative_path = request.path_url
        if relative_path.startswith(base_path):
            relative_path = relative_path[len(base_path):]
        return generate_qsh(request.method, relative_path)

    return qsh_generator


def iat(*_):
    return int(time.time())


def outgoing_authenticator(base_url, shared_secret, addon_key):
    jwt_auth = requests_jwt.JWTAuth(shared_secret, alg='HS256',
                                    header_format='JWT %s')
    jwt_auth.expire(600)
    jwt_auth.add_field('iat', iat)
    jwt_auth.add_field('iss', addon_key)
    jwt_auth.add_field('qsh', qsh(base_url))
    return jwt_auth

requests.get('https://your-url', auth-outgoing_authenticator(<tenant base url>, <tenant shared secret>, <addon key>))

Hope that’s useful to you. One of these days I’ll publish a library :wink:

3 Likes

Code’s a little off. Is “auth-outgoing_authenticator” supposed to be just “outgoing_authenticator”?

Making that change gives me a “TypeError: cannot concatenate ‘str’ and ‘JWTAuth’ objects”

Ah. It’s “auth=outgoing_authenticator”. Changing that means it runs, but I still get 401 errors.

The URL I’m trying to hit is “https://testtablet.atlassian.net/rest/api/latest/issue/TEST-2”. Can someone explain to me what part is the “tenant base url”? The documentation says to discard the “context path” but I have no idea what that refers to in Atlassian Cloud.

I just tried it with every combination of “context_path” and “base url”. No dice. I even unregistered and reregistered my plugin.

Can someone explain why Atlassian hasn’t released a working library or sample code for this?

The team working on connect for JIRA / Confluence haven’t done a lot with Python, besides a bit of work during innovation weeks. atlassian-jwt was developed by the Bitbucket team, for Bitbucket connect, which unfortunately is a little bit different. That’s why there isn’t more sample code out there, in particular for JIRA / Confluence, and it may be why the library is not playing nicely. I’ve had success with it, though. I’ll see if I can distil what I’ve done into a single runnable working sample.

To answer your earlier question, the ‘tenant base url’ is https://testtablet.atlassian.net for JIRA and https://testtablet.atlassian.net/wiki for Confluence.

I got a trivial add-on working with atlassian-jwt and I think I know what the issue was: we should be providing the addon key as the ‘clientKey’ parameter because that’s what JIRA and Confluence Connect expect the iss to be. Below is the listing for a minimal add-on (Python 2.7) that pings the REST api and prints the result to show that it was able to authenticate.

It listens on port 5000. You will need to provide a publicly-accessible https url (e.g. the url you get from running ngrok http 5000) as the first argument.

requirements.txt:

atlassian-jwt==1.6
click==6.7
Flask==0.12
itsdangerous==0.24
Jinja2==2.9.5
MarkupSafe==1.0
PyJWT==1.4.2
requests==2.13.0
Werkzeug==0.11.15
wheel==0.24.0

addon.py:

import sys

import atlassian_jwt
import requests
from flask import Flask, request, jsonify, redirect

ADDON_KEY = "simple-flask-addon"
clients = {}

if __name__ == '__main__':
    app = Flask(__name__)
    descriptor = {
        "key": ADDON_KEY,
        "baseUrl": sys.argv[1],
        "authentication": {"type": "jwt"},
        "scopes": ["READ"],
        "lifecycle": {
            "installed": "/register",
            "enabled": "/ping",
        },
    }


    class SimpleAuthenticator(atlassian_jwt.Authenticator):
        def get_shared_secret(self, client_key):
            return clients[client_key]['sharedSecret']


    auth = SimpleAuthenticator()


    @app.route('/', methods=['GET'])
    def redirect_to_descriptor():
        return redirect('/descriptor')


    @app.route('/descriptor', methods=['GET'])
    def get_descriptor():
        return jsonify(descriptor)


    @app.route('/register', methods=['POST'])
    def register():
        client = request.get_json()
        clients[client['clientKey']] = client
        return '', 204


    @app.route('/ping', methods=['POST'])
    def ping():
        client_key = auth.authenticate(request.method, request.url, request.headers)
        client = clients[client_key]
        ping_url = '/rest/api/latest/issue/TEST-2'
        jwt_authorization = 'JWT %s' % atlassian_jwt.encode_token('GET', ping_url, ADDON_KEY, client['sharedSecret'])
        result = requests.get(client['baseUrl'].rstrip('/') + ping_url, headers={'Authorization': jwt_authorization})
        result.raise_for_status()
        print '%s - %s' % (result.status_code, result.json())
        return '', 204


    app.run(debug=True)
7 Likes

Thanks. This is now working.

3 Likes

@jhazelwood This doesn’t seem to work anymore. Furthermore, Python package is not maintained https://bitbucket.org/atlassian/atlassian-jwt-py/src/master/atlassian_jwt/ (last update 2017)

Can you shed some lights what we are supposed to use? Thanks

Hi @chhantyal, our officially-supported frameworks are for Javascript and Java. Please see this docs page for more info: https://developer.atlassian.com/cloud/jira/platform/frameworks-and-tools/

If you can share a stack trace or some more info about what’s failing when you attempt to use python, I may be able to help, though.

@jhazelwood Thank you for your reply. I actually even tried Javascript package because Python didn’t work. Here I still have issue with qsh generation i.e generated qsh is not right.

var req = jwt.fromMethodAndUrl('GET', 'https://foo.atlassian.net/wiki/rest/api/content/278757378/child/attachment');
console.log(jwt.createQueryStringHash(req));

This gives me: 109fbd808c66e8cbf5e00f7380a8c2d0a2080cb3fe0c3af101e18cdff05a840d

However, when I use this, server gives error.

 Expecting claim 'qsh' to have value '2cc02088cd855427b2c7fd5b597dc7a1c6c0fb5585aad7c5c14012930c877f33' but instead it has the value '109fbd808c66e8cbf5e00f7380a8c2d0a2080cb3fe0c3af101e18cdff05a840d'

I was also able to generate qsh in Python after changing some stuffs in the package. But as it is, this qsh is not accepted. Note, the URL has no query params.

@chhantyal I used their Javascript package and see the same ‘qsh’ value issue. Can I know what you ended up doing?

Is something wrong with the JS library or am I making a mistake somewhere? Can someone please help from Atlassian? @jhazelwood

The request we sent was:

Method: POST
URL: https://prod-api.zephyr4jiracloud.com/connect/public/rest/api/1.0/cycle
Headers: {
“Connection”: “keep-alive”,
“content_type”: “application/json”,
“authorization”: “JWT ${myJWTtoken}”,
“zapiaccesskey”: “${issuer}”,
“user-agent”: “Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Cypress/3.6.1 Chrome/73.0.3683.121 Electron/5.0.10 Safari/537.36”,
“accept”: “/”,
“accept-encoding”: “gzip, deflate”,
“content-type”: “application/json”,
“content-length”: 68
}
Body: {“name”:“8:45:2 11-15-2019”,“projectId”:“myID”,“versionId”:“myID”}


The response we got was:

Status: 401 - Unauthorized
Headers: {
“content-encoding”: “gzip”,
“content-type”: “text/plain;charset=utf-8”,
“date”: “Fri, 15 Nov 2019 16:45:02 GMT”,
“server”: “Apache-Coyote/1.1”,
“vary”: “Accept-Encoding”,
“content-length”: “164”,
“connection”: “keep-alive”
}
Body: Expecting claim ‘qsh’ to have value ‘dc8e37069edfb14c506ee47e1c4480b52d1058e44e284a2079a305736d249a0b’ but instead it has the value ‘23432a414394e528c3de984cc5a7292081dc8995ca55cd7a97ae57b5da525589’

If anyone needs it, after getting source code for this library in JS (https://bitbucket.org/atlassian/atlassian-jwt-js/src/master/)

we will have to modify req to a POST

const req = jwt.Request = jwt.fromMethodAndUrl(‘POST’, ‘https://prod-api.zephyr4jiracloud.com/connect/public/rest/api/1.0/cycle’);

AND

add extra parameters to qsh
“qsh”: jwt.createQueryStringHash(req, cycle_url, base_url)

So sorry I haven’t had a chance to get back to you yet! Did you get it working? And was it necessary to modify the source of the library or did it just show you how to fix up the order / type of parameters?

If you’re still stuck I’ll try and take a look on Monday (Sydney time).

Cheers,
James

@chhantyal could you please elaborate on what wasn’t working? Although it’s true those packages don’t see a lot of maintenance, I just grabbed that sample code from my 2017 and had no trouble with either the /ping or the /register endpoints. It also worked when I upgraded to the latest version of the libraries.

Next up I’ll try and write a javascript equivalent, although it seems like you solved the problem yourself, @SanthoshSonti, if I’m understanding correctly.

Here’s a js version which works fine for me.

Perhaps the issues above were due to mis-matched methods, or maybe not having the scopes that a given resource requires?

package.json:

{
  "name": "js-jwt-test",
  "version": "0.0.1",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "atlassian-jwt": "^1.0.2",
    "axios": "^0.19.0",
    "body-parser": "^1.19.0",
    "express": "^4.17.1"
  }
}

index.js

const jwt = require('atlassian-jwt');
const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');

const baseUrl = process.argv[2];
const appKey = 'js-jwt-test';

const app = express();
app.use(bodyParser.json());

const clients = {};

app.get('/', (_, res) => {
  res.redirect('/descriptor');
});

app.get('/descriptor', (_, res) => {
  res.json({
    key: appKey,
    baseUrl: baseUrl,
    authentication: { type: 'jwt' },
    scopes: ['READ'],
    lifecycle: {
      installed: '/register',
      enabled: '/ping',
    },
  });
});

app.post('/register', (req, res) => {
  clients[req.body.clientKey] = req.body;
  res.sendStatus(204);
});

app.post('/ping', async (req, res) => {
  const clientKey = authenticate(req);
  const secret = clients[clientKey].sharedSecret;
  const clientBaseUrl = clients[clientKey].baseUrl;

  const path = '/rest/api/latest/issue/TEST-2';
  const response = await axios.get(path, {
    baseURL: clientBaseUrl,
    headers: {
      'Authorization': `JWT ${sign(secret, 'GET', path)}`,
    },
  });
  console.log(response.data);
  res.sendStatus(204);
});

function authenticate(req) {
  const token = req.headers.authorization.substring('JWT '.length);
  const rawPayload = jwt.decode(token, '', true);
  const secret = clients[rawPayload.iss].sharedSecret;
  const verifiedPayload = jwt.decode(token, secret);
  if (verifiedPayload.qsh !== jwt.createQueryStringHash(jwt.fromExpressRequest(req), false, baseUrl)) {
    throw new Error('query string hashes did not match');
  }
  return verifiedPayload.iss;
}

function sign(secret, method, url) {
  const nowMs = (new Date()).getTime()
  return jwt.encode({
    iss: appKey,
    aud: appKey,
    iat: nowMs - 1000,
    exp: nowMs + 10000,
    qsh: jwt.createQueryStringHash(jwt.fromMethodAndUrl(method, url)),
  }, secret);
}

app.listen(6000);

to run:

node index.js https://base-url-of-your-app