From 300f379979fa24282a6d3091a75934dfbe82b20d Mon Sep 17 00:00:00 2001 From: "renyuneyun (Rui Zhao)" Date: Sat, 3 Feb 2024 22:51:06 +0000 Subject: [PATCH] feat: Add WebID attestation for requests to llm_service This version uses Header parameter for retrieving header. But `Optional` does not seem to be respected correctly. --- .gitignore | 2 + chat_app/main.py | 11 +- llm_service/attest.js | 55 +++++++ llm_service/main.py | 43 +++++- llm_service/package-lock.json | 260 ++++++++++++++++++++++++++++++++++ llm_service/package.json | 5 + llm_service/solid_utils.py | 13 ++ 7 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 llm_service/attest.js create mode 100644 llm_service/package-lock.json create mode 100644 llm_service/package.json create mode 100644 llm_service/solid_utils.py diff --git a/.gitignore b/.gitignore index e19e75f..9d20fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +node_modules/ \ No newline at end of file diff --git a/chat_app/main.py b/chat_app/main.py index 24dddd1..38f90c0 100644 --- a/chat_app/main.py +++ b/chat_app/main.py @@ -5,6 +5,7 @@ from langchain_core.chat_history import BaseChatMessageHistory from langchain.schema import messages_to_dict from chat_app.solid_message_history import SolidChatMessageHistory +from solid_oidc_client import SolidAuthSession hostname = os.environ.get("WEBSITE_HOSTNAME") if hostname is not None: @@ -96,6 +97,11 @@ def print_state_messages(history: BaseChatMessageHistory): st.markdown(message.content) +def get_auth_headers(st, url, method): + solid_auth = SolidAuthSession.deserialize(st.session_state["solid_token"]) + return solid_auth.get_auth_headers(url, method) + + def main(): st.set_page_config(page_title="Social Gen Pod", page_icon="🐢") show_pages([ @@ -134,12 +140,15 @@ def main(): history.add_user_message(prompt) with st.spinner("LLM is thinking...."): + url = "http://localhost:5000/completions/" + auth_headers = get_auth_headers(st, url, 'POST') response = requests.post( - "http://localhost:5000/completions/", + url, json={ "model": selected_llm, "messages": messages_to_dict(history.messages), }, + headers=auth_headers, ) st.session_state["input_disabled"] = False if not response.ok: diff --git a/llm_service/attest.js b/llm_service/attest.js new file mode 100644 index 0000000..47490b7 --- /dev/null +++ b/llm_service/attest.js @@ -0,0 +1,55 @@ +const createSolidTokenVerifier = + require("@solid/access-token-verifier").createSolidTokenVerifier; + +/** + * Check whether the request belongs to a / the corresponding WebID. + * @param {string} authorizationHeader The `authorization` header + * @param {string} dpopHeader The `DPoP` header + * @param {string} requestMethod The HTTP method for the request + * @param {string} requestURL The URL of the request + * @param {string|undefined} claimedWebid What WebID the client claims to be (can be `undefined`) + * @returns {boolean|string} If `claimedWebid` is not empty, return whether the claimed WebID matches the real WebID in the credentials; otherwise, return the real WebID. + */ +async function attestWebidPossession( + authorizationHeader, + dpopHeader, + requestMethod, + requestURL, + claimedWebid +) { + const solidOidcAccessTokenVerifier = createSolidTokenVerifier(); + + try { + const { client_id: clientId, webid: webId } = + await solidOidcAccessTokenVerifier(authorizationHeader, { + header: dpopHeader, + method: requestMethod, + url: requestURL, + }); + + if (!claimedWebid) { + return webId; + } + + return webId == claimedWebid; + } catch (error) { + const message = `Error verifying Access Token via WebID: ${error.message}`; + throw new Error(message); + } +} + +// module.exports = { +// attestWebidPossession, +// }; + +async function main() { + const res = await attestWebidPossession(...process.argv.slice(2)); + + if (res) { + process.exit(0); + } else { + process.exit(1); + } +} + +main(); diff --git a/llm_service/main.py b/llm_service/main.py index 745f491..d3bc8ae 100644 --- a/llm_service/main.py +++ b/llm_service/main.py @@ -1,10 +1,12 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Depends, Header, Request, HTTPException from pydantic import BaseModel import uvicorn +from typing import Optional from langchain.schema import messages_from_dict from .chains import make_conversation_chain from .config import get_config +from .solid_utils import attessPossession app = FastAPI() config = get_config() @@ -15,6 +17,40 @@ class ChatCompletionRequestData(BaseModel): messages: list[dict] +def as_header(cls): + """decorator for pydantic model + replaces the Signature of the parameters of the pydantic model with `Header` + See https://github.com/tiangolo/fastapi/issues/2915 + """ + cls.__signature__ = cls.__signature__.replace( + parameters=[ + arg.replace( + default=Header(...) if arg.default is arg.empty else Header(arg.default) + ) + for arg in cls.__signature__.parameters.values() + ] + ) + return cls + + +@as_header +class WebIdDPoPInfoHeader(BaseModel): + authorization: str + dpop: str + x_forwarded_host: Optional[str] + x_forwarded_protocol: Optional[str] + webid: Optional[str] + + +def checkIdentity(request: Request, hdrs: WebIdDPoPInfoHeader): + method = 'POST' + host = hdrs.x_forwarded_host or request.url.hostname # Use X-Forwarded-For in case there is a reverse proxy in-between the client and the server + protocol = hdrs.x_forwarded_protocol or request.url.scheme # Same as above + path_prefix = '/' # Needed if deployed to a (sub)path instead of root of the hostname + request_url = f"{protocol}://{host}{path_prefix}{request.url.path}" + return attessPossession(hdrs.authorization, hdrs.dpop, method, request_url, hdrs.webid) + + @app.get("/") def read_root(): return {"Hello": "World"} @@ -62,7 +98,10 @@ def create_embeddings(): @app.post("/completions/") -def chat_completion(req: ChatCompletionRequestData) -> str: +def chat_completion(req: ChatCompletionRequestData, request: Request, hdrs: WebIdDPoPInfoHeader = Depends(WebIdDPoPInfoHeader)) -> str: + if not checkIdentity(request): + raise HTTPException(400, detail='WebID attestation failed') + selected_model_idx = -1 for idx, llm in enumerate(config["llms"]): if llm["model"] == req.model: diff --git a/llm_service/package-lock.json b/llm_service/package-lock.json new file mode 100644 index 0000000..7224ff2 --- /dev/null +++ b/llm_service/package-lock.json @@ -0,0 +1,260 @@ +{ + "name": "llm_service", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@solid/access-token-verifier": "^2.1.0" + } + }, + "node_modules/@solid/access-token-verifier": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@solid/access-token-verifier/-/access-token-verifier-2.1.0.tgz", + "integrity": "sha512-79u92GD1SBTxjYghg2ta6cfoBNZ5ljz/9zE6RmXUypTXW7oI18DTWiSrEjWwI4njW+OMh+4ih+sAR6AkI1IFxg==", + "dependencies": { + "jose": "^5.1.3", + "lru-cache": "^6.0.0", + "n3": "^1.17.1", + "node-fetch": "^2.7.0", + "ts-guards": "^0.5.1" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/jose": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.0.tgz", + "integrity": "sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/n3": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.17.2.tgz", + "integrity": "sha512-BxSM52wYFqXrbQQT5WUEzKUn6qpYV+2L4XZLfn3Gblz2kwZ09S+QxC33WNdVEQy2djenFL8SNkrjejEKlvI6+Q==", + "dependencies": { + "queue-microtask": "^1.1.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/ts-guards": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/ts-guards/-/ts-guards-0.5.1.tgz", + "integrity": "sha512-Y6P/VJnwARiPMfxO7rvaYaz5tGQ5TQ0Wnb2cWIxMpFOioYkhsT8XaCrJX6wYPNFACa4UOrN5SPqhwpM8NolAhQ==" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/llm_service/package.json b/llm_service/package.json new file mode 100644 index 0000000..0308964 --- /dev/null +++ b/llm_service/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@solid/access-token-verifier": "^2.1.0" + } +} diff --git a/llm_service/solid_utils.py b/llm_service/solid_utils.py new file mode 100644 index 0000000..d1df749 --- /dev/null +++ b/llm_service/solid_utils.py @@ -0,0 +1,13 @@ +import subprocess + + +def attessPossession(authorizationHeader, dpopHeader, requestMethod, requestURL, claimedWebid): + ''' + To attest the user is actually the user it claims to be + Or, maybe, at least, to attest that the user holds valid credentials -- being *a* valid Solid user + ''' + args = [authorizationHeader, dpopHeader, requestMethod, requestURL] + if claimedWebid: + args.append(claimedWebid) + ret = subprocess.call(('node', 'attest.js', *args), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return not ret