Hello Fermyon friends and welcome back to the second blog post in the series on Building a Social Application with Spin. If you haven’t read it yet, take some time to go back and read the first blog post where we set the stage for the project and set up our first CRUD API component. In this blog post, we’re going to cover creating our Vue.js app, setting up a simple OAuth service and adding some authorization guards to our CRUD API. But first, let’s talk about my “oopsie” that I realized shortly after the last blog post.
Building a social app with Spin (1/4): Project Setup and first API
As I mentioned in the first blog post, I’m going to make some mistakes but we’ll have to adapt and move on. I didn’t do my homework on our own SDK (doh!) and didn’t realize that MySQL wasn’t supported in all of our languages. In the meantime I refactored the code to use PostgreSQL instead because it is supported in all of our languages. It was fairly straightforward given the simple and small usage of MySQL at our current stage.
Confession over, let’s get started!
Setting up a Vue.js App
We’re not going to spend too much time describing the front-end application because we’re focusing on building a Spin application. It has been a while since I’ve created a front-end application from scratch so I started looking around at some popular front-end frameworks. The one that piqued my interest the most was Vue.js, I really like how components can be described in one file and it seems easy enough to understand. Another framework that I’m seeing a lot of buzz around is TailwindCSS and then I found TailwindUI which has a lot of great free component examples. A lot of the front-end components from TailwindUI will be re-used here so we can stay focused on our primary goal of building a Spin application.
Let’s get going on setting up our static fileserver. With the Spin SDK updates and inclusion of spin add ...
, I found this to be really easy:
❯ npm init vue@latest
Vue.js - The Progressive JavaScript Framework
✔ Project name: … web
✔ Add TypeScript? … No / Yes
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes
✔ Add Pinia for state management? … No / Yes
✔ Add Vitest for Unit Testing? … No / Yes
✔ Add an End-to-End Testing Solution? › Playwright
✔ Add ESLint for code quality? … No / Yes
✔ Add Prettier for code formatting? … No / Yes
Scaffolding project in ~/code-things/web...
Done. Now run:
cd web
npm install
npm run lint
npm run dev
❯ cd web
❯ npm install
❯ npm run build
❯ spin add static-fileserver web
HTTP path: /...
Directory containing the files to serve: web/dist
By default the Vue.js app will be created at ./web
because that is our project name and when you run npm run build
it will build the static files and write them to ./web/dist
. Then we tell the static fileserver to serve that directory statically. For a more in-depth description of the static fileserver component checkout the blog post Serving Static Content via WebAssembly written by our own Mikkel Hegnhøj. One improvement we can make here is to let Spin build our Vue.js app for us by giving it a component.build.command
and component.build.workdir
in our spin.toml
file. Then we’ll be able to spin build --up
to build and run both of our components.
...
[[component]]
source = { url = "https://github.com/fermyon/spin-fileserver/releases/download/v0.0.1/spin_static_fs.wasm", digest = "sha256:650376c33a0756b1a52cad7ca670f1126391b79050df0321407da9c741d32375" }
id = "web"
files = [ { source = "web/dist", destination = "/" } ]
[component.trigger]
route = "/..."
[component.build]
command = "npm run build"
workdir = "web"
Now let’s re-run the scripts/validate.sh
script to make sure the API is still working.
❯ ./scripts/validate.sh
Creating the profile
HTTP/1.1 404 Not Found
content-length: 9
date: Mon, 30 Jan 2023 21:26:28 GMT
Not Found
------------------------------------------------------------
Fetching the profile
HTTP/1.1 404 Not Found
content-length: 9
date: Mon, 30 Jan 2023 21:26:28 GMT
Not Found
------------------------------------------------------------
Updating the avatar
HTTP/1.1 404 Not Found
content-length: 9
date: Mon, 30 Jan 2023 21:26:28 GMT
Not Found
------------------------------------------------------------
Deleting profile
HTTP/1.1 404 Not Found
content-length: 9
date: Mon, 30 Jan 2023 21:26:28 GMT
Not Found
------------------------------------------------------------
Fetching after delete should be 404
HTTP/1.1 404 Not Found
content-length: 9
date: Mon, 30 Jan 2023 21:26:28 GMT
Not Found
------------------------------------------------------------
Routing
The script is getting 404’s for every endpoint now so something is mis-configured. If I look at the output from spin I’m seeing a lot of Cannot read file: cannot open /api/profile/justin
errors. To me this reads like our new static-fileserver component is handling the routes for our profile API. Searching through the Spin issues on GitHub, I found one that hints at our problem.
After reading the issue, it does seem like the order our components are listed in spin.toml
matters to the route handler. In a future update to Spin, the HTTP route handling algorithm with change to the “longest route wins”. More details can now be found in the HTTP Trigger section on our docs website or you can check out the Pull Request in the Spin GitHub Repo. Currently our spin.toml
lists the profile API component first and our static component second. Let’s swap the order of those components and re-run the validation script to see if that resolves our issue.
❯ ./scripts/validate.sh
Creating the profile
HTTP/1.1 201 Created
location: /api/profile/justin
content-length: 0
date: Mon, 30 Jan 2023 22:07:30 GMT
------------------------------------------------------------
Fetching the profile
HTTP/1.1 200 OK
content-type: application/json
content-length: 126
date: Mon, 30 Jan 2023 22:07:30 GMT
{"id":"A4916AC2-AB0D-45A9-ADEA-959F8DEB2A14","handle":"justin","avatar":"https://avatars.githubusercontent.com/u/3060890?v=4"}
------------------------------------------------------------
Updating the avatar
HTTP/1.1 200 OK
content-type: application/json
content-length: 122
date: Mon, 30 Jan 2023 22:07:30 GMT
{"id":"A4916AC2-AB0D-45A9-ADEA-959F8DEB2A14","handle":"justin","avatar":"https://avatars.githubusercontent.com/u/3060890"}
------------------------------------------------------------
Deleting profile
HTTP/1.1 204 No Content
date: Mon, 30 Jan 2023 22:07:30 GMT
------------------------------------------------------------
Fetching after delete should be 404
HTTP/1.1 404 Not Found
content-length: 0
date: Mon, 30 Jan 2023 22:07:30 GMT
------------------------------------------------------------
Success! Our profile API passed our checks and I can still navigate to http://127.0.0.1:3000
and see the home page of our static application. Clicking around the templated Vue.js application, it does look like we have another issue here. From the home page I’m able to click on the “About” button and the About view does render but when I refreshed the page or tried to directly navigate to http://127.0.0.1:3000/about
I’m greeted with a “Not Found” page. This makes sense because our static fileserver is really simple in that it is only going to respond with files that it finds in ./web/dist
which does not contain a file called about
. There are a few ways I think we could handle this.
Redirects
We could use the spin-redirect module to redirect from http://127.0.0.1:3000/about
to http://127.0.0.1:3000
. This would work but then we would have to use hash-based routes like http://127.0.0.1:3000/#/about
which is not good for SEO or deep-linking.
Update spin-fileserver to support our use-case
Knowing that the spin-fileserver is simple and open source, we could also change the static fileserver to support our use case. The change should be simple, we just need to catch the 404 response and return the contents of index.html
instead.
Given the downsides of using redirects, I elected to update the spin-fileserver to support our use case. Now we just need to fork, clone and take a look at the code. At first glance, this looks like a good place to start:
let body = match FileServer::read(path, &enc) {
Ok(b) => Some(b),
Err(e) => {
eprintln!("Cannot read file: {:?}", e);
return not_found();
}
};
If we can adjust the error case to read the index.html file instead of returning 404 the vue-router should be able to render the correct view on the front-end:
// read from the fallback path if the variable exists
let body = match FileServer::read(path, &enc) {
// requested file was found
Ok(b) => Some(b),
Err(e) => match std::env::var(FALLBACK_PATH_ENV) {
// try to read the fallback path
Ok(fallback_path) => FileServer::read(fallback_path.as_str(), &enc).ok(),
// fallback path config not found
Err(_) => {
eprintln!("Cannot read file: {e:?}");
None
}
},
};
I was able to compile the project and copy the WebAssembly module to my repository and reference my version instead of the official version. All that was needed from my application was to adjust my spin.toml
point to a local wasm file instead of referencing it from the GitHub release.
[[component]]
id = "web"
source = "modules/spin_static_fs.wasm"
environment = { FALLBACK_PATH = "index.html" }
After some local testing the Vue.js app is now able to render the http://127.0.0.1:3000/about
route, still serve all of my assets and the profile API is still functioning the same as before. I did take the time to submit a pull request to spin-fileserver with this improvement, hopefully you all can benefit from the changes.
Auth0 Setup
We’re able to create our profile so we’ve gotten to the exciting (for me, at least) part of this blog post: Authentication & Authorization. I’m going to assume that you’re already familiar with OAuth2.x; if you’re not, I would recommend spending some time coming up to speed here. I didn’t want to spend too much of my time setting up an authorization server like AAD B2C or Amazon Cognito so I just went with a basic developer environment with Auth0. It’s super simple to use and connect to GitHub which will suffice for the purposes of this project. We won’t spend too much time describing Auth0 but at a high level, these are the steps to setup your own Auth0 authorization server:
- Sign up for Auth0 account (free)
- Create a “Single Page Application” in Auth0 Dashboard (see also: Developer Guide)
- Configure callback URLs:
http://127.0.0.1:3000
- Configure logout URLs:
http://127.0.0.1:3000
- Configure web origins:
http://127.0.0.1:3000
- Add GitHub Connection
- Configure callback URLs:
- Create API
- Name:
Code Things API
- Identifier:
https://code-things-<your app suffix>.fermyon.app
- Signing Algorithms:
RS256
- Name:
- Add the Auth0 configuration to Vue.js
- Create a file at
./web/.env.local
(this is gitignored) - Add domain:
VITE_AUTH0_DOMAIN = "dev-xxx.us.auth0.com"
- Add client id:
VITE_AUTH0_CLIENT_ID = "xxx"
- Add audience:
VITE_AUTH0_AUDIENCE = "https://code-things.fermyon.app/api"
- Create a file at
Again, we’re not going to spend too much time on the front-end setup as there are a lot of good resources out there to help you do that. You can follow the same Vue.js guide here. At this point I’m going to assume that you have cloned the code, setup an authorization server and copied the configuration to the local environment variable file for the Vue.js application. You can see how I added Auth0 to the application in the following commit.
Token Verification in Profile API
If you’re up to date with the latest commit, you should be able to open the website, sign-in using your GitHub account and attempt to create your profile. I say attempt because it should be returning a 500 error because the id
field is empty. This is intentional as we’re now going to tackle JWT verification and use the sub
(or subject) field of the token to assign the identifier sourced from Auth0. The output should be something like this:
> spin build --up
...
Successfully ran the build command for the Spin components.
Serving http://127.0.0.1:3000
Available Routes:
web: http://127.0.0.1:3000 (wildcard)
profile-api: http://127.0.0.1:3000/api/profile (wildcard)
AUTHORIZATION: "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkJXazhuR0xnYVMyNmJuSjF6YkJrcyJ9.eyJpc3MiOiJodHRwczovL2Rldi1jemhubmw4aWtjb2pjMDQwLnVzLmF1dGgwLmNvbS8iLCJzdWIiOiJnaXRodWJ8MzA2MDg5MCIsImF1ZCI6WyJodHRwczovL2NvZGUtdGhpbmdzLmZlcm15b24uYXBwL2FwaSIsImh0dHBzOi8vZGV2LWN6aG5ubDhpa2NvamMwNDAudXMuYXV0aDAuY29tL3VzZXJpbmZvIl0sImlhdCI6MTY3NTMzODQzOCwiZXhwIjoxNjc1NDI0ODM4LCJhenAiOiJnem9GM3hob2tWZG1PZWVhV1FNdURzdURIT3pOYVZXbiIsInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJwZXJtaXNzaW9ucyI6W119.WFCJoxeqpvb95wWA32ZdT8zwXYti53S6pfgiTd_1HAId5mtQF-oT6vO5wwY64yulAHyzrdE8rZSQLzB-sZIDO_9IpF8TEArljI88TXKKgeRiF61c0YJWXM2aCeTz5JrRoYAhtfdmx04EZ6vuPvntAaGgk0r52C83oO2rU349LRIFuWAT8E5ylhMXV8nGZ9iJMNKlUaGH84S4MIZ1LYrWQI5Ge4o_YqMvrvjvr9PfaBx9u7DeH77mb7RV0KJp49j1C1WvgR_ZmDu5ZyL5nfsCzh9sMAptRt7r_ShrH3kC_smjT7JOpMX_DNjr7TBwcAwk8k4u--1GE_KYclOJeqi1rA"
Handler returned an error: The id field is currently required for insert
The other thing we can see in the log messages is the authorization header. Obviously we will want to remove this before merging the pull request but it’s helpful for debugging purposes. We know the authorization header is being passed to the API and I’ve checked that this token is valid using the debugger tool at jwt.io.
Now we’re ready to start verifying the JWT token. The first thing we need is the public key associated with our Auth0 server. There are a few ways we can accomplish this, we could add the public key to the Spin component’s source files so that we can read it from the filesystem or we could utilize the publicly available Json Web Key Set (JWKS) at https://<domain>/.well-known/jwks.json
. At a high level we need to perform the following steps:
- Get the public key for RS256 asymmetric verification
- Use
spin_sdk::config::get
to read verification options - Verify the JWT
- Guard the handlers
Read the RS256 Public Key
The two different methods that I found to extract the public key for verification were: download the .peb
file from the Auth0 admin console and fetching the JWKS. The file from Auth0 did require some manual extraction of the public key using OpenSSL so I chose to fetch the JWKS and extract the exponent and modulus components and use those to create the key. This does mean that we’re making an external HTTP call for each request but in a future blog post, we’ll look at using the upcoming Key-Value store as a cache to improve performance.
impl JsonWebKey {
pub fn to_rsa256_public_key(self) -> Result<RS256PublicKey> {
let n = BASE64_ENGINE.decode(self.modulus)?;
let e = BASE64_ENGINE.decode(self.exponent)?;
Ok(RS256PublicKey::from_components(&n, &e)?
.with_key_id(self.identifier.as_str()))
}
}
...
pub fn get(url: &str) -> Result<Self> {
let res = outbound_http::send_request(
http::Request::builder()
.method("GET")
.uri(url)
.body(None)?
)?;
let res_body = match res.body().as_ref() {
Some(bytes) => bytes.slice(..),
None => bytes::Bytes::default(),
};
Ok(serde_json::from_slice::<JsonWebKeySet>(&res_body)?)
}
Verifying the JWT
Once we have an instance of the public key, we can move on to verification of the JWT token. The first thing I do is to pull some basic verification options from our configuration object. Your use case may differ and you may want to perform additional verification here as well. The options configured here allow us to verify that the token was created by our Auth0 domain (i.e. issuer), for this API (i.e. audience), it was created within the last day and the subject matches the identifier parsed from the request. I think this is a decent set of verifications on top of verifying the token’s signature using the public key.
fn claims_from_request(
cfg: &Config,
req: &Request,
subject: &Option<String>,
) -> Result<JWTClaims<NoCustomClaims>> {
let keys = auth::JsonWebKeySet::get(cfg.jwks_url.to_owned())
.context(format!("Failed to retrieve JWKS from {:?}", cfg.jwks_url))?;
let token = get_access_token(req.headers()).ok_or(anyhow!(
"Failed to get access token from Authorization header"
))?;
let options = VerificationOptions {
max_validity: cfg.auth_max_validity,
allowed_audiences: Some(cfg.auth_audiences.to_owned()),
allowed_issuers: Some(cfg.auth_issuers.to_owned()),
required_subject: subject.to_owned(),
..Default::default()
};
println!("[DEBUG] {:#?}", options);
let claims = keys
.verify(token, Some(options))
.context("Failed to verify token")?;
Ok(claims)
}
Verify the token’s signature
Finally we can perform the verification using the public key and the configured options! One more piece of verification here is to assert that the key identifier from the JWT matches the current key we are iterating over. A smarter implementation here would be to parse the JWT’s header and lookup that key specifically but I just chose an easy path here.
pub fn verify(
self,
token: &str,
options: Option<VerificationOptions>,
) -> Result<JWTClaims<NoCustomClaims>> {
for key in self.keys {
let key = key.to_rsa256_public_key()?;
// add a required key id verification to options
let options = options.clone().map(|o| VerificationOptions {
// ensure the token is validated by this key specifically
required_key_id: key.key_id().to_owned(),
..o
});
let claims = key.verify_token::<NoCustomClaims>(token, options);
if claims.is_ok() {
return claims;
}
}
bail!("No key in the set was able to verify the token.")
}
Handler Guards
To wrap things up, we just need to add a guard to our CRUD handlers. I did a little refactor here to separate the parsing of the request and mapping of the request to an API action. Other than the refactor, the logic should be the same from the first blog post. Here we also had to make a choice between verifying the token first or parsing the request first. I chose the latter so I could make sure the requestor is only accessing their own profile.
#[http_component]
fn profile_api(req: Request) -> Result<Response> {
let cfg = Config::default();
// parse the profile from the request
let method = req.method();
let profile = match parse_profile(method, &req) {
Ok(profile) => profile,
Err(e) => return bad_request(e),
};
// guard against unauthenticated requests
let claims = match claims_from_request(&cfg, &req, &profile.id) {
Ok(claims) if claims.subject.is_some() => claims,
Ok(_) => return forbidden("Token is missing 'sub'.".to_string()),
Err(e) => return forbidden(e.to_string()),
};
// add the subject to the profile
let profile = profile.with_id(claims.subject);
// match api action to handler
match api_from_profile(method, profile) {
Api::Create(profile) => handle_create(&cfg.db_url, profile),
Api::Update(profile) => handle_update(&cfg.db_url, profile),
Api::ReadById(id) => handle_read_by_id(&cfg.db_url, id),
Api::DeleteById(id) => handle_delete_by_id(&cfg.db_url, id),
Api::MethodNotAllowed => method_not_allowed(),
Api::NotFound => not_found(),
}
}
Final Thoughts
🎉 Now we have a web app that can perform JWT verification before committing data to the database! One thing that I’ll briefly mention as we wrap up: beware of libraries that use OpenSSL as a dependency. Only because it is an incompatible dependency for WebAssembly, not necessarily Spin in particular. I had a slightly difficult time finding one that works but eventually found the jwt-simple crate. Another thing to be aware of is to make sure the language you are targeting supports crypto in WebAssembly, which is required for token verification. I already knew that Rust has support for the crypto libraries but that’s not guaranteed for all languages. I intend on making the authentication code its own component someday so that other languages can make internal calls and still get token verification. We covered a lot of ground today so be sure to reach out on Discord or leave some comments in the code-things GitHub repo. Thanks for reading!