The last post (starting OAuth) tackled the first pass at the app installation API that serves Shopify’s OAuth flow. It also covered declaring the redirect URI, so Shopify sends the application server a web request, the server responds with a redirect to Shopify to authorize the installation, and then Shopify sends the user back to the application server.
A quick refresher on Shopify’s described OAuth flow:
1.
https://shopify.dev/apps/auth/oauth#the-oauth-flowThe merchant makes a request to install the app.
2.The app redirects to Shopify to load the OAuth grant screen and requests the required scopes.
3.Shopify displays a prompt to the merchant to give authorization to the app, and prompts the merchant to log in if required.
4.The merchant consents to the scopes and is redirected to theredirect_uri
.
5. The app makes an access token request to Shopify including theclient_id
,client_secret
, andcode
.
6. Shopify returns the access token and requested scopes.
7. The app uses the access token to make requests to the Shopify API.
8. Shopify returns the requested data.
We have an authorized request knocking at the door of the application server (/callback
API) but we’re not doing anything with it. In this post we’re going to tackle the body of:
// main.rs
#[rocket::get("/callback")]
pub fn callback() -> &'static str {
"Now what..."
}
The goal here is to address: “The app makes an access token request to Shopify including the client_id
, client_secret
, and code
.” If you take a close look at the output of the previous attempt to install an application, it seems we’ve missed quite a few parameters on the callback API:
Specifically on:
GET /callback?code=8a99f98c7deb52660f76219dee5ada50&hmac=6cb4c00bf66110d41cb4d7e26c59ade80d3b322de7ca0f28e54ac9b8dace4f4b&host=dG9ieS10ZXN0LXN0b3JlMi5teXNob3BpZnkuY29tL2FkbWlu&shop=toby-test-store2.myshopify.com&state=random-value×tamp=1639244833 text/html
There are 6 query parameters!
- code: The authorization code provided in the redirect, this is what we were after in the previous step and will use in the next step to get a token!
- looks like:
8a99f98c7deb52660f76219dee5ada50
- looks like:
- hmac: This is a signature we’ll use later to verify the request is unaltered from Shopify, it’s a bit ol’ TO-DO right now though
- looks like:
6cb4c00bf66110d41cb4d7e26c59ade80d3b322de7ca0f28e54ac9b8dace4f4b
- looks like:
- host: base64 encoded host name appended with the /admin URL segment
- looks like
dG9ieS10ZXN0LXN0b3JlMi5teXNob3BpZnkuY29tL2FkbWlu
- decoded, looks like:
toby-test-store2.myshopify.com/admin
- looks like
- shop: The plaintext name of the merchant’s shop (without trailing /admin)
- looks like:
toby-test-store2.myshopify.com
- looks like:
- state: The nonce we provided in the installation redirect URL
- looks like:
random-value
right now, but we’ll fix that eventually…
- looks like:
- timestamp: seconds since epoch of the request, I think?
- looks like:
1639244833
- looks like:
The timestamp
, state
, and hmac
we can ignore entirely for the time being (this is a development app, we can’t ignore these in production). The host
is useful, but not immediately, so let’s ignore it for the time being as well. The code
and shop
are exactly what we need for the next step. First, let’s make them accessible in our request handler:
// main.rs
#[rocket::get("/callback?<code>&<shop>")]
pub fn callback(code: &str, shop: &str) -> &'static str {
println!("The code is: {}", code);
println!("The shop is: {}", shop);
"Now what..."
}
Now, what are we ultimately doing with these values? We have a code that represents the Shopify store’s agreement to install the application with the requested permissions. Now that we have that code, we’re going back to Shopify to say “Look, the shop agreed – can we access their data?”. This happens by exchanging the authorization code for an access token. The access token will let us make requests to Shopify that are authorized to access the granting store’s data. Before we dive into that too much, lets actually get an access token.
We need the application server to make a request. To do so, we’ll need an HTTP client. Looking at lib.rs, reqsounds godwest seems like it may be overkill but will likely do the job. We’re using virtually no features of the HTTP client library, and in this case it has to be synchronous regardless of whether the underlying code is sync or async.
Add reqwest
to your Cargo.toml
:
[package]
name = "template"
version = "0.1.0"
edition = "2021"
[dependencies]
rocket = "0.5.0-rc.1"
reqwest = { version = "0.11" }
Now let’s send an HTTP POST to https://{shop}/admin/oauth/access_token. In the spirit of just doing stuff, let’s give it a shot and see what happens. We’ll ignore Reqwest’s advice to share a client for the time being (another TO-DO?) and go for it. Lets also take the opportunity to change the return type to a Result
just because it makes the code a bit easier to work with.
// main.rs
#[rocket::get("/callback?<code>&<shop>")]
pub async fn callback(code: &str, shop: &str) -> Result<&'static str, &'static str> {
println!("The code is: {}", code);
println!("The shop is: {}", shop);
let access_token_uri = format!("https://{}/admin/oauth/access_token", shop);
let client = reqwest::Client::new();
let response = client
.post(access_token_uri)
.send()
.await
.map_err(|_| "Error sending request")?;
println!("Response status: {}", response.status());
let response_body = response
.text()
.await
.map_err(|_| "Error getting response body as text")?;
println!("{}", response_body);
Ok("Now what...")
}
Some good things: the request itself succeeds in sending and a response comes back. Areas for improvement, the request yields an HTTP 400 Bad Request and the body is HTML. If you render the HTML, it looks something like this:
As it says on Shopify, this is a JSON body with 3 properties:
client_id
: this is our application’s API key, coming from the Shopify partner dashboard for the appclient_secret
: this is our applications API secret key, also coming from the Shopify partner dashboard for the appcode
: this is the code query parameter from the callback URL we have is populated
So, lets grab the values and try again! client_id
and client_secret
come from the Shopify partner dashboard:
The code
is a query parameter in the original request. We’re crafting a JSON request, and basically as soon as anyone mentions JSON it’s time to add the Serde crate. While we’re there, we’ll enable the json
feature of Reqwest so we can use Serde:
[package]
name = "template"
version = "0.1.0"
edition = "2021"
[dependencies]
rocket = "0.5.0-rc.1"
reqwest = { version = "0.11", features = ["json"] }
serde = "1"
One of the easiest ways I’ve found to craft a JSON request is to define a type for the request body and then let Serde serialize it to JSON. Adding the appropriate import and a stuct definition:
// main.rs
use rocket::{response::Redirect, routes, serde::Serialize};
#[derive(Serialize)]
struct AccessTokenRequest<'a> {
client_id: &'a str,
client_secret: &'a str,
code: &'a str,
}
Now we can use that and amend our request so it contains the correct body:
#[rocket::get("/callback?<code>&<shop>")]
pub async fn callback(code: &str, shop: &str) -> Result<&'static str, &'static str> {
let access_token_uri = format!("https://{}/admin/oauth/access_token", shop);
let body = AccessTokenRequest {
client_id: "801f137828580f71e33ad6b14f75e533",
client_secret: "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",
code,
};
let client = reqwest::Client::new();
let response = client
.post(access_token_uri)
.json(&body)
.send()
.await
.map_err(|_| "Error sending request")?;
println!("Response status: {}", response.status());
let response_body = response
.text()
.await
.map_err(|_| "Error getting response body as text")?;
println!("{}", response_body);
Ok("Now what...")
}
Start up the application server, try installing or opening the app and:
We have an access token for the shop! As a side note, this is effectively a password that can be used to access a store’s data. Never reveal the value, and be diligent to not let it show up in screenshots in a blog post or similar.
As a quality of life improvement, let’s also deserialize the response body so it’s easier to use. As a quirk of the deserialization, we can only use owned types, so we need String
instead of slices:
// main.rs
use rocket::{
response::Redirect,
routes,
serde::{Deserialize, Serialize},
};
#[derive(Deserialize, Debug)]
struct AccessTokenResponse {
access_token: String,
scope: String,
}
#[rocket::get("/callback?<code>&<shop>")]
pub async fn callback(code: &str, shop: &str) -> Result<&'static str, &'static str> {
...
let response_body: AccessTokenResponse = response
.json()
.await
.map_err(|_| "Error getting response body as JSON")?;
println!("{:?}", response_body);
...
Once again, referring back to the Shopify OAuth flow we’ve now tackled two more steps:
1.
https://shopify.dev/apps/auth/oauth#the-oauth-flowThe merchant makes a request to install the app.
2.The app redirects to Shopify to load the OAuth grant screen and requests the required scopes.
3.Shopify displays a prompt to the merchant to give authorization to the app, and prompts the merchant to log in if required.
4.The merchant consents to the scopes and is redirected to theredirect_uri
.
5.The app makes an access token request to Shopify including theclient_id
,client_secret
, andcode
.
6.Shopify returns the access token and requested scopes.
7. The app uses the access token to make requests to the Shopify API.
8. Shopify returns the requested data.
Status
Where we started in this post: We had an app that “installable” and we could both request and be granted permissions for a store installing the app. Unfortunately we could not leverage those permissions to do anything
Where we ended with this post: We’ve exchanged the ephemeral authorization a store grants us for a permanent authorization. In practice this looks like using the provided code
value for an OAuth access token. With this token, we can (theoretically at this point) make requests to Shopify for data that belongs to the associated store
Next post: Make an authorized request and obtain a shop’s data!