Skip to content

Instantly share code, notes, and snippets.

@SimonShapiro
Last active February 16, 2022 05:02
Show Gist options
  • Save SimonShapiro/a58c4eeb167c747fa2398889abe5b7ee to your computer and use it in GitHub Desktop.
Save SimonShapiro/a58c4eeb167c747fa2398889abe5b7ee to your computer and use it in GitHub Desktop.
This gist is a python (anvil) snapshot of the auth flow against NSS. It contains the auth process up to the redirect to NSS and the callback code.
import anvil.tables as tables
import anvil.tables.query as q
from anvil.tables import app_tables
import anvil.server
import requests
import json
import pprint
from jwcrypto import jwk
import uuid
import jwt
from anvil.tables import app_tables
import base64
# This is a server module. It runs on the Anvil server,
# rather than in the user's browser.
#
# To allow anvil.server.call() to call functions here, we mark
# them with @anvil.server.callable.
# Here is an example - you can replace it with your own:
#
# @anvil.server.callable
# def say_hello(name):
# print("Hello, " + name + "!")
# return 42
###################################################################
# This annotated code describes the authorization code flow for Python and Anvil to obtain an id_token
# from a node-solid-server.
#
# It is based on the recomendations as at 2019-07-14 from the node-solid-server. Steps mentioned in this annotation
# refer to steps in the pull request found at https://github.com/solid/webid-oidc-spec/pull/27/files. Variations
# have been made to accomodate Python and authorization code flow itself, as the original is for 'implicit' flow.
#
# The expecation of the implicit flow is that it all takes place in the browser. The "authorization code flow has both
# a "front-channel" flow that yields a "code" which usually takes place in the browser; followed by a back-channel
# "code" for "token" exchange that takes place in a server call-back route between the node-solid-server and the server
# through which the client is operating.
#
# In the code that follows, functions with the @anvil.server.callable decorator can be thought of as taking place in the
# browser when called by the browser. This is a result of the Anvil architecture which makes these functions architecturally
# similar to a (web)worker call.
#
# Functions decorated with @anvil.server.http_endpoint are server paths as in any Flask or Express server architecture.
#
# A consequence of the architecture is that the two parts of this code need to communicate with each other. While there are
# multiple mechanisms to effect this, I have chosen to do this via an in-server database.
# This might change after I fully consider the security implications of holding certain values in a persistent mechanism.
# The original spec requires this storage to be browser localStorage, but that was for "implicit" flow which didn't
# need the back-channel.
#####################################################################
@anvil.server.callable
def nss_login(webid):
"""
Given a webid, this function returns a redirect url in accordance with the oauth authorization code flow.
It also has a side-effect as it stores up values to be used by the code for token exchange which happens between
the node-solid-server and the call-back to this server.
It is called from the browser. On obtaining the redirect url, the browser redirects to the provided url
"""
print(webid)
# HTTP OPTIONS on the webid allows us to inspect the resulting links for the iri below in order to establish the
# openid provider (OP)
resp = requests.request(url=webid, method="OPTIONS")
openid_provider = resp.links["http://openid.net/specs/connect/1.0/issuer"]["url"]
print("OP=", openid_provider)
# A GET request on the url below provides the configuration options available from the openid provider.
resp = requests.request(
method = "GET",
url = openid_provider+"/.well-known/openid-configuration"
)
print(resp.json())
# All stored values are held in the Anvil seesion variable until being committed to the server database
# at the end of the function
anvil.server.session["LOCAL_STORAGE"] = {}
# using local_storage makes the code similar to the node-solid-server pull request for comparision purposes
local_storage = anvil.server.session["LOCAL_STORAGE"]
local_storage["OPENID_CONFIGURATION"] = resp.json()
print(local_storage)
# Step 5 - Generate a private and public key pair for later signing.
key = jwk.JWK.generate(kty='RSA', size=2048)
private_json = json.loads(key.export_private())
print(private_json)
local_storage["RP_PRIVATE_KEY"] = {"alg": "RS256", "ext":True,
"key_ops":[
"verify"
],
**private_json
}
public_json = json.loads(key.export_public())
local_storage["RP_PUBLIC_KEY"] = {"alg": "RS256", "ext":True,
"key_ops":[
"sign"
],
**public_json
}
# Step 6 - SHow that the keys have been added to local storage above
print(local_storage.keys())
# Step 7 - Obtain a keys set from the node-solid-server
resp = requests.request(
method = "GET",
url = local_storage["OPENID_CONFIGURATION"]["jwks_uri"]
)
# Step 8 - Store the key set in local_storage
local_storage["OP_JWKS"] = resp.json()
# Step 9 - Perform dynamic reqistration at the node-solid-server. This will provide the client_id and client_secret
# to be used in the exchange of the "code" for the "token" later in the call-back route. They are persisted in the
# database as a side-effect
data = {
"grant_types": ["authorization_code"], #["implicit"], #
"issuer": "https://inrupt.net",
"redirect_uris": ["https://nss_authenticate.anvil.app/_/api/solid_callback"], # what happens with localhost
"response_types": ["code"],
"scope": "openid profile"
}
resp = requests.request(
method = "POST",
url = local_storage["OPENID_CONFIGURATION"]["registration_endpoint"],
json = data
)
# Step 10 - The openid provider has also persisted this inforamation for use during later stages.
# Step 11 - Save the client info to local_storage
print(resp)
print("==========CLIENT_REGISTRATION_RESPONSE===============")
print(resp.json())
local_storage["CLIENT_REGISTRATION_RESPONSE"] = resp.json()
print(local_storage.keys())
# Step 12 - This is the main request for authorization. Its purpose is the obtain an authorization code.
# In order to do so it prepares a jwt with the content in raw_request
nonce = uuid.uuid4()
raw_request = {
"redirect_uri": "https://nss_authenticate.anvil.app/_/api/solid_callback",
"display": "page",
"nonce": nonce.hex,
"key": local_storage["RP_PUBLIC_KEY"]
}
raw_request["key"]["key_ops"] = ['verify']
# node-solid-server does not use the private key just yet.
#rehydrate = jwk.JWK.from_json(json.dumps(local_storage["RP_PRIVATE_KEY"]))
#rehydrated_private = rehydrate.export_to_pem(private_key=True, password=None)
request_jwt = jwt.encode(raw_request, None, algorithm=None)
# The auth_state is simply a random string used in the call-back to check whether the code came from this routine.
# It is passed to node-solid-server and then returned to the call-back as proof.
local_storage["auth_state"] = uuid.uuid4().hex
# Finally, the GET request to authorize. If everything has been successful a well formed url can be paased onto the browser.
# The url will take the user through the node-solid-server login (and if appropriate) consent process.
resp = requests.request(
url = "https://inrupt.net/authorize",
method = "GET",
params = {
"scope": "openid",
"client_id": local_storage["CLIENT_REGISTRATION_RESPONSE"]["client_id"],
"response_type": "code", #"id_token token", #"auhtorization_code", #
"state": local_storage["auth_state"],
"request": request_jwt
}
)
# SIDE-EFFECT - Persist required information in the server against the client_id
app_tables.openid_client_params.add_row(client_id=local_storage["CLIENT_REGISTRATION_RESPONSE"]["client_id"],
client_secret=local_storage["CLIENT_REGISTRATION_RESPONSE"]["client_secret"],
private_key=local_storage["RP_PRIVATE_KEY"],
auth_state=local_storage["auth_state"],
token_endpoint=local_storage["OPENID_CONFIGURATION"]["token_endpoint"],
registration_response=local_storage["CLIENT_REGISTRATION_RESPONSE"]["registration_access_token"]
)
print(resp)
print("+++++++++++++++++++++++++++++++++++++++++++++++++++")
print(f"redirected url = {resp.url}")
# Return the redirect url
return resp.url
@anvil.server.http_endpoint("/solid_callback")
def register_solid(code, state):
"""
This is a call-back route and therefore, node-solid-server has created the request object and it contains the cient_id.
At this point the user has successfully completed the login (and possible consent) process at the openid provider.
As a result it returns the code (which is used here to be exchanged for an id_token) and echos the auth_state created
during step 11 above.
The code is exchanged for a token which includes an id_token and everythin required to renew it.
As a side-effect the token if persisted against the client_id.
"""
print(f"|code={code} & state={state}|")
r = anvil.server.request
# Create a dictionary from the referer section of the request object
headers_dict = {x.split('=')[0]: x.split("=")[1] for x in r.headers["referer"].split('&')}
token_endpoint = ""
# Retrieve the information required for the "code for token excahnge" from the database
auth_params = app_tables.openid_client_params.get(client_id=headers_dict["client_id"])
# Raise an AssertionError if the state value from node-solid-server is not the same as that persisted earlier
assert state == auth_params["auth_state"]
# Get the token_endpoint and the client_secret from the database. We already have the client_id
token_endpoint = auth_params["token_endpoint"]
client_secret = auth_params["client_secret"]
print(headers_dict["client_id"], type(r.headers["referer"]))
print(f"query = {r.query_params}, headers = {r.headers}, body = {r.body}, creds = {r.password}, {r.user}, orogon = {r.origin}, path = {r.path}")
# Perform the "code for token" exchange
body = {
"grant_type": "authorization_code", # "bearer", #
"code": code,
"redirect_uri": "https://nss_authenticate.anvil.app/_/api/solid_callback"
}
auth = (headers_dict["client_id"]+":"+client_secret) #state)
print(f"|{auth}|")
auth_base64 = base64.b64encode(bytes(auth, "utf-8")).decode("ascii")
print(f"|Basic {auth_base64}|")
resp = requests.request(
url=token_endpoint,
method="POST",
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded", #json, #x-www-form-urlencoded
"Authorization": f"Basic {auth_base64}"
}
)
print(resp, dir(resp))
r = resp
print(f"headers = {r.headers}, content = {r.content}")
# Persist the result of the token exchange
if resp.status_code == 200:
auth_params["token"] = resp.json()
return

pop_token

{'iss': '04f576620a243986e7b11674d435063f', 
'aud': 'https://anvil1.inrupt.net',
 'exp': 1564403772,
 'iat': 1563194173, 
'id_token': 'eyJhbGciOiJSUzI1NiIsImtpZCI6InZaaUNkZGVIbHQ0In0.eyJpc3MiOiJodHRwczovL2lucnVwdC5uZXQiLCJzdWIiOiJodHRwczovL2FudmlsMS5pbnJ1cHQubmV0L3Byb2ZpbGUvY2FyZCNtZSIsImF1ZCI6IjA0ZjU3NjYyMGEyNDM5ODZlN2IxMTY3NGQ0MzUwNjNmIiwiZXhwIjoxNTY0NDAzNzcyLCJpYXQiOjE1NjMxOTQxNzIsImp0aSI6ImY3NGRlMWUxOTQ0ZjExY2YiLCJub25jZSI6ImYxN2NhOGVlOWUwNzQ3ZWQ4NDZkNzhhZGYzYzNhYjRiIiwiYXpwIjoiMDRmNTc2NjIwYTI0Mzk4NmU3YjExNjc0ZDQzNTA2M2YiLCJhdF9oYXNoIjoiazZNakJjV3BhQnR1Q0NMdVgzdHlLQSJ9.SWEdPYVP-QWxgJjeDJ8CkQ6jvVpBrzlo3OUjDQFfXE0C5C4h9RG9QKJ1YUDUrZYFDbDQMcaSskkGL9Hud0iYARoWQNQg3CF4R47Oi4hin3vf39tbFdXSYQjVtWIiSyeHaB_scfVHRu8LnO2-g9PqLKhW5H-397DSP6nyWzlyYzPy_I8NDmwfVU56JdpXC0eRBA9LCHtAxnlGDSSXNYwiHAqTB2tm_lJ8RctKF3K_9RoKWgq4LnsLMK7W81Lguf7XemvfFPysyXJKYk_pUpFnHeOgQqXuSTRM7RlSpmilF9n9quMUPFFOWGmjAkrszULxbd5Tzy0LPmCMsr7OWHkelQ',
 'token_type': 'pop'}

I'm getting status 401 Unauthorized. The header has an error message that reads: error="invalid_token", error_description="Missing cnf key in access token" The jwt.io of the id_token is:

{
  "iss": "https://inrupt.net",
  "sub": "https://anvil1.inrupt.net/profile/card#me",
  "aud": "f884f42d27fc8208db7c455d19f017b4",
  "exp": 1564426196,
  "iat": 1563216596,
  "jti": "fd3ac02b62a2f1a4",
  "nonce": "c72b7a70885f4fbf805723bccfe47a4b",
  "azp": "f884f42d27fc8208db7c455d19f017b4",
  "at_hash": "AKBN4ETzgNb7FNu-md9HGw"
}

I noticed this in the solid-auth-client id_token which is not in the above:

cnf': {'jwk': {'alg': 'RS256', 'e': 'AQAB', 'ext': True, 'key_ops': ['verify'], 'kty': 'RSA', 'n': 'xyEq8yDWGStPHz5VoBXmviOa3gp4Aoko7tsneYh5TEKnwd9Etkgs-bQu08HYqmtskB2a6goII9R_F1qkvz6clj5a65WYKGNk_104DyIaTK95RjBMvPwyGtLbJusfXjTiyhe6rYiNVjvYr3Ihh2FpgU9icSXlmoErb0Gu63LTGo6oRTiiWQ7-xzXZERPomluKd9OtGyq4x3eiOXLfInJZy-hut69jR_j7o0Xn40TRv6ERAF1s9ihNEpt2vACHfOyMaIxi3Tq_WH8o31-1Mjc6xBkY2pJg-_ZXjK6flY3jk5eJLDjI_PSIMieTOcisJuSulwP5HeIP0TraRRR0lP9-ow'}}

I'm not to sure why it wasn't in the id_token received from .../token

Apparently, this an known NSS bug and I will update the gist when then has been remediated.

@SimonShapiro
Copy link
Author

With thanks for the hours of debugging and support of Michael Thornburgh, I have provided the annotated code of the working code that performs the authorization code flow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment