Skip to content

Commit c9ec6bb

Browse files
morlinbrotiuioiua
andauthored
docs: add a PKCE authentication with Supabase example (freshframework#2302)
Closes freshframework#1217 Adds an example on how to implement the PKCE authentication flow with Supabase. I included a link to a repo of mine holding the code to a fully fleshed out example app as a more comprehensive point of reference, hope that is ok. In fact, I was thinking of deploying that app on Deno Deploy and add it to the Showcase section, lmk if that would be a good idea. --------- Co-authored-by: Asher Gomez <ashersaupingomez@gmail.com>
1 parent 1d69f16 commit c9ec6bb

1 file changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
---
2+
description: |
3+
Learn how to implement the PKCE authentication flow using Supabase.
4+
---
5+
6+
Fresh is a great tool for quickly building lightweight, server-side rendered web
7+
apps and Supabase provides an easy way to add authentication (and/or a
8+
PostgreSQL database backend) to your app.
9+
10+
In this example, we'll create a small app that implements the PKCE
11+
authentication flow using Supabase.
12+
13+
The PKCE authentication flow is designed specifically for applications that
14+
cannot store a client secret, such as native mobile apps or server-side rendered
15+
web apps. You can read up on the specifics of PKCE
16+
[here](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce)
17+
or have a look at
18+
[its specification](https://datatracker.ietf.org/doc/html/rfc7636). Our example
19+
is based on the information you can piece together from the
20+
[Supabase documentation](https://supabase.com/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr)
21+
on the topic.
22+
23+
The purpose of the example app we're building here is to showcase the basic
24+
building blocks of an implementation. As such, it is limited in functionality
25+
and purposefully leaves out things like
26+
[password resets](https://supabase.com/docs/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr),
27+
[proper error handling](https://fresh.deno.dev/docs/concepts/error-pages) as
28+
well as validating input form data. You can find the
29+
[full code here](https://github.com/morlinbrot/supa-fresh-pkce), where the
30+
missing functionality is implemented.
31+
32+
## Supabase
33+
34+
First of all, we need a Supabase account
35+
[which can be created for free here](https://supabase.com/). A handy way to
36+
supply the credentials to our app is via `.env` file (never check in `.env`
37+
files to version control).
38+
39+
```txt .env.example
40+
SUPABASE_URL=https://<projectName>.supabase.co
41+
SUPABASE_ANON_KEY=<api_key>
42+
```
43+
44+
Update the imports section of your `deno.json` file to include the following:
45+
46+
```json deno.json
47+
"imports": {
48+
"supabase": "npm:@supabase/supabase-js@2",
49+
"supabase/ssr": "npm:@supabase/ssr",
50+
}
51+
```
52+
53+
Since Deno 1.38, we reading .env files is built-in and can be enabled with the
54+
`--env` flag. Here's the complete command to run our app:
55+
56+
```shell
57+
deno run --unstable-kv --allow-env --allow-read --allow-write --allow-run --allow-net --watch=static/,routes/ dev.ts
58+
```
59+
60+
### `@supabase/ssr`
61+
62+
Supabase provides the `@supabase/ssr` package for working with its API in an SSR
63+
context. It exposes the `createServerClient` method that we can use on the
64+
server side. Set it up like so:
65+
66+
```ts lib/supabase.ts
67+
import { deleteCookie, getCookies, setCookie } from "$std/http/cookie.ts";
68+
import { assert } from "$std/assert/assert.ts";
69+
import { type CookieOptions, createServerClient } from "supabase/ssr";
70+
71+
export function createSupabaseClient(
72+
req: Request,
73+
// Keep this optional parameter in mind, we'll get back to it.
74+
resHeaders = new Headers(),
75+
) {
76+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
77+
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
78+
79+
assert(
80+
SUPABASE_URL && SUPABASE_ANON_KEY,
81+
"SUPABASE URL and SUPABASE_ANON_KEY environment variables must be set.",
82+
);
83+
84+
return createServerClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
85+
auth: { flowType: "pkce" },
86+
cookies: {
87+
get(name: string) {
88+
return decodeURIComponent(getCookies(req.headers)[name]);
89+
},
90+
set(name: string, value: string, options: CookieOptions) {
91+
setCookie(resHeaders, {
92+
name,
93+
value: encodeURIComponent(value),
94+
...options,
95+
});
96+
},
97+
remove(name: string, options: CookieOptions) {
98+
deleteCookie(resHeaders, name, options);
99+
},
100+
},
101+
});
102+
}
103+
```
104+
105+
Note: We are specifying the `flowType` to be `pkce` and that we're using
106+
[`encodeURIComponent()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)
107+
to serialize and store the session object as a cookie.
108+
109+
Crucially, _we need to create a new instance of this client for each request!_
110+
111+
## Sign Up
112+
113+
In our endpoints, we can now use this client to talk to the Supabase API. Here's
114+
the `/api/sign-up` handler:
115+
116+
```ts routes/api/sign-up.ts
117+
import { FreshContext, Handlers } from "$fresh/server.ts";
118+
import { createSupabaseClient } from "lib/supabase.ts";
119+
120+
export const handler: Handlers = {
121+
async POST(req: Request, _ctx: FreshContext) {
122+
const form = await req.formData();
123+
const email = form.get("email");
124+
const password = form.get("password");
125+
126+
const headers = new Headers();
127+
headers.set("location", "/sign-in"); // Redirect to /sign-in on success.
128+
129+
const supabase = createSupabaseClient(req);
130+
const { error } = await supabase.auth.signUp({
131+
email: String(email),
132+
password: String(password),
133+
});
134+
135+
if (error) throw error; // Have a look at the full app for proper error handling.
136+
137+
return new Response(null, { status: 303, headers });
138+
},
139+
};
140+
```
141+
142+
Create a form to call our API endpoint and render it at `/sign-up`:
143+
144+
```tsx routes/sign-up.tsx
145+
export default function SignUpPage() {
146+
return (
147+
<form action="/api/sign-up" method="post">
148+
<input autofocus type="email" name="email" />
149+
<input type="password" name="password" />
150+
<button type="submit">Submit</button>
151+
</form>
152+
);
153+
}
154+
```
155+
156+
## Confirmation
157+
158+
To complete the sign-up process, we need a `/confirm` route to intercept
159+
successful email confirmations:
160+
161+
```ts routes/api/confirm.ts
162+
import { Handlers } from "$fresh/server.ts";
163+
import { createSupabaseClient } from "lib/supabase.ts";
164+
165+
export const handler: Handlers = {
166+
async GET(req: Request) {
167+
const { searchParams } = new URL(req.url);
168+
const token_hash = searchParams.get("token_hash");
169+
const type = searchParams.get("type") as EmailOtpType | null;
170+
const next = searchParams.get("next") ?? "/welcome";
171+
172+
const redirectTo = new URL(req.url);
173+
redirectTo.pathname = next;
174+
175+
if (token_hash && type) {
176+
const supabase = createSupabaseClient(req);
177+
const { error } = await supabase.auth.verifyOtp({ type, token_hash });
178+
if (error) throw error; // Have a look at the full app for proper error handling.
179+
}
180+
181+
redirectTo.searchParams.delete("next");
182+
return Response.redirect(redirectTo);
183+
},
184+
};
185+
```
186+
187+
Have a look at the Supabase docs on the
188+
[details on how to configure email templates and other endpoints](https://supabase.com/docs/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr)
189+
like `/password-reset` you would need for a full implementation.
190+
191+
## Sign In
192+
193+
The `/api/sign-in` route is pretty straight-forward, too:
194+
195+
```ts routes/api/sign-in.ts
196+
import { Handlers } from "$fresh/server.ts";
197+
import { createSupabaseClient } from "lib/supabase.ts";
198+
199+
export const handler: Handlers = {
200+
async POST(req) {
201+
const form = await req.formData();
202+
const email = form.get("email")!;
203+
const password = form.get("password")!;
204+
205+
const headers = new Headers();
206+
headers.set("location", "/");
207+
208+
const supabase = createSupabaseClient(req, headers);
209+
const { error } = await supabase.auth.signInWithPassword({
210+
email,
211+
password,
212+
});
213+
214+
if (error) throw error; // Have a look at the full app for proper error handling.
215+
216+
return new Response(null, { status: 303, headers });
217+
},
218+
};
219+
```
220+
221+
Note: We're passing `headers` this time. The Supabase client will set the
222+
session as a cookie for us, which we will want to pick up in the middleware that
223+
we are writing next.
224+
225+
## Middleware
226+
227+
We can now write a middleware that will check the auth status of any request,
228+
guarding any protected routes. You can read up on middlewares and where to put
229+
them [in the docs](https://fresh.deno.dev/docs/concepts/middleware).
230+
231+
```ts routes/_middleware.ts
232+
import { FreshContext } from "$fresh/server.ts";
233+
import { createSupabaseClient } from "lib/supabase.ts";
234+
235+
export const handler = [
236+
async function authMiddleware(req: Request, ctx: FreshContext) {
237+
const url = new URL(req.url);
238+
const headers = new Headers();
239+
headers.set("location", "/");
240+
241+
const supabase = createSupabaseClient(req, headers);
242+
// Note: Always use `getUser` instead of `getSession` as this calls the Supabase API and revalidates the token.
243+
const { error, data: { user } } = await supabase.auth.getUser();
244+
245+
const isProtectedRoute = url.pathname.includes("secret");
246+
247+
// Don't mind 401 as it just means no credentials were provided. E.g. There was no session cookie.
248+
if (error && error.status !== 401) throw error; // Have a look at the full app for proper error handling.
249+
250+
if (isProtectedRoute && !user) {
251+
return new Response(null, { status: 303, headers });
252+
}
253+
254+
ctx.state.user = user;
255+
256+
return ctx.next();
257+
},
258+
];
259+
```
260+
261+
That's it! These are the building blocks for implementing the PKCE
262+
authentication flow in a Fresh app using Supabase. Again, have a look at the
263+
[full code here](https://github.com/morlinbrot/supa-fresh-pkce) for a fully
264+
featured version of the app.

0 commit comments

Comments
 (0)