|
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 |
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.