NWC Container Auth Token Exchange

The uma-nwc-server Docker image does not have its own user database or authentication system. Instead, it relies on your VASP's existing login flow to authenticate users and generate short-lived JWTs. These tokens are then exchanged for longer-lived tokens that the NWC server uses to authenticate NWC requests for each connection.
When the user loads a page in the NWC image frontend for the first time, they will be redirected to your VASP's login page that you configured in the UMA_VASP_LOGIN_URL variable of the . This should generally use your existing login flow to authenticate the user. Once the user is authenticated (or if they were already logged in), you should generate a short-lived JWT and redirect back to the NWC image with the JWT in the URL. For example:
  1. The user loads the NWC image and is redirected to your login page at http://localhost:5001/auth/nwclogin?redirect_uri=https://localhost:8080/apps/new.
  2. If the user is already logged in, skip this step, otherwise, the user logs in.
  3. After login, generate a short-lived JWT in your backend with the user's ID and any other necessary information. For example:
import jwt

# First double-check that the redirect_uri is one that you know comes from the NWC image.
nwc_container_domain = config.get("NWC_APP_ROOT_URL")
parsed_uri = urlparse(redirect_uri)
if not parsed_uri.scheme == "https" or not parsed_uri.netloc:
    return "Invalid redirect_uri", 400
if not parsed_uri.netloc.endswith(nwc_container_domain):
    return "Invalid redirect_uri", 400

user_nwc_jwt = jwt.encode(
    {
        # The user's ID in your system.
        "sub": user_id,
        # Your vasp domain is the audience and issuer of this token.
        "aud": "examplevasp.com",
        "iss": "examplevasp.com",
        # Let this token only last 10 minutes. You can adjust this if needed.
        "exp": datetime.timestamp(datetime.now() + timedelta(minutes=10)),
        # The NWC image will use this to show the user's UMA in the UI.
        "address": user.get_uma_address(),
    },
    # Store this in secrets!
    jwt_private_key,
    # The algorithm must be ES256 for the NWC image to verify it.
    algorithm="ES256",
)
jwt_private_key should be a valid ES256 private key that you can generate with the help of jwt.io or a similar tool. You should store this key securely and not expose it in your code. You also need to configure the public key in the NWC image configuration in the UMA_VASP_JWT_PUBKEY variable to verify this JWT (see the UMA NWC Docker Image guide).
  1. Redirect back to the NWC image with the JWT in the URL as a token query param, and the user's JSON-encoded default currency as another param. For example, if the user's default currency is USD, their currency info might be:
currency = {"code": "USD", "symbol": "$", "decimals": 2, "name": "US Dollar"}
And you'd redirect back to the NWC image like this:
# append the token and currency to the redirect_uri's query string:
query_params = parse_qs(parsed_url.query)
query_params["token"] = [user_nwc_jwt]
query_params["currency"] = [json.dumps(currency)]
new_query_string = urlencode(query_params, doseq=True)
new_url = urlunparse(parsed_url._replace(query=new_query_string))

return redirect(new_url)
This allows the NWC image to authenticate the user and show their UMA address and default currency in the UI. The short-lived token can only be used as authentication for the /info request and to exchange for a long-lived token.
When the NWC image receives the short-lived JWT, it will show the user the OAuth/NWC permission page. When the user approves, it will make a POST request to your VASP's token exchange endpoint that you configured above in the UMA_VASP_TOKEN_EXCHANGE_URL variable. This endpoint should verify the short-lived JWT, and if it's still valid, return a long-lived JWT that the NWC image can use as the bearer token for requests to your VASP's UMA Auth API. For example:
POST /umanwc/token HTTP/1.1
Content-Type: application/json
Header: Authorization Bearer <short-lived JWT>

{
  "permissions": ["get_balance", "pay_invoice", "pay_to_address"],
  "expiration": 1630000000
}
To handle this request, you can use the following Python code as an example:
import jwt

def user_id_from_jwt(request):
    auth_header = request.headers.get("Authorization")
    if not auth_header:
        abort_with_error(401, "Unauthorized")
    jwt_token = auth_header.split("Bearer ")[-1]
    jwt_public_key = current_app.config.get("NWC_JWT_PUBKEY")
    if not jwt_public_key:
        abort_with_error(500, "JWT public key not configured")

    # TODO: You might also want to check for revoked tokens here if you have a revocation list.

    try:
        decoded = jwt.decode(
            jwt_token,
            key=jwt_public_key,
            algorithms=["ES256"],
            issuer="examplevasp.com",
            audience="examplevasp.com",
        )
        user_id = decoded.get("sub")
        return user_id
    except jwt.exceptions.InvalidTokenError as e:
        abort_with_error(401, "Unauthorized")

@app.route("/token", methods=["POST"])
def handle_token_exchange():
    user_id = user_id_from_jwt(request)
    user = User.from_id(user_id)
    if user is None:
        return jsonify({"error": f"User {user_id} not found."}), 404
    body = request.get_json()
    requested_permissions = body.get("permissions")
    if not requested_permissions:
        return jsonify({"error": "Permissions are required."}), 400
    requested_expiration = body.get("expiration")

    jwt_private_key = current_app.config.get("NWC_JWT_PRIVKEY")
    if not jwt_private_key:
        return jsonify({"error": "JWT private key not set in config."}), 500

    claims = {
        "sub": str(user_id),
        "aud": "examplevasp.com",
        "iss": "examplevasp.com",
        "address": user.get_uma_address(),
    }

    if requested_permissions:
        claims["scopes"] = requested_permissions

    if requested_expiration:
        claims["exp"] = requested_expiration

    user_nwc_jwt = jwt.encode(
        claims,
        jwt_private_key,
        algorithm="ES256",
    )
    return jsonify({"token": user_nwc_jwt})
Make sure that in your VASP server UMA Auth API implementation, you verify the long-lived JWT and use the user's ID from the JWT to make requests on their behalf.
@app.route("/payments/lud16", methods=["POST"])
def make_payment():
    user_id = user_id_from_jwt(request) # this might raise a 401 if the JWT is invalid
    user = User.from_id(user_id)
    if user is None:
        return jsonify({"error": f"User {user_id} not found."}), 404
    # ... make the payment
The NWC image already checks all the permissions and budget constraints before making a request to your VASP's API, so you can trust that the user has the necessary permissions to make the request once you've verified the JWT. However, if you still want to check the permissions in your API, you can use the scopes claim in the JWT to see what permissions the user has granted. See the details of that mapping in the "Permissions" section of the VASP UMA Auth OpenAPI Schema guide.