From 39c8a06f46a3788e463453fc743a18c98ca067ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thor=20=E9=9B=B7=E7=A5=9E=20Schaeff?= <5748289+thorwebdev@users.noreply.github.com> Date: Tue, 22 Mar 2022 08:35:07 +0800 Subject: [PATCH] feat: 1.3.0 release (#47) - [#44](https://github.com/supabase-community/supabase-auth-helpers/pull/43): feat: Add `withMiddlewareAuthRequired` Nextjs Middleware util to protect directories. - [#43](https://github.com/supabase-community/supabase-auth-helpers/pull/43): feat: store `provider_token` in cookie. --- CHANGELOG.md | 5 + .../nextjs/pages/github-provider-token.tsx | 52 ++++++++ examples/nextjs/pages/index.tsx | 11 ++ .../pages/middleware-protected/_middleware.ts | 3 + .../pages/middleware-protected/index.tsx | 5 + src/nextjs/README.md | 57 +++++++++ src/nextjs/handlers/callback.ts | 30 +++-- src/nextjs/handlers/logout.ts | 2 +- src/nextjs/index.ts | 1 + .../utils/withMiddlewareAuthRequired.ts | 118 ++++++++++++++++++ src/shared/adapters/NextMiddlewareAdapter.ts | 34 +++++ 11 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 examples/nextjs/pages/github-provider-token.tsx create mode 100644 examples/nextjs/pages/middleware-protected/_middleware.ts create mode 100644 examples/nextjs/pages/middleware-protected/index.tsx create mode 100644 src/nextjs/utils/withMiddlewareAuthRequired.ts create mode 100644 src/shared/adapters/NextMiddlewareAdapter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b059616a..b466c879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 1.3.0 - 2022-03-19 + +- [#44](https://github.com/supabase-community/supabase-auth-helpers/pull/43): feat: Add `withMiddlewareAuthRequired` Nextjs Middleware util to protect directories. +- [#43](https://github.com/supabase-community/supabase-auth-helpers/pull/43): feat: store `provider_token` in cookie. + ## 1.2.3 - 2022-03-16 - [#42](https://github.com/supabase-community/supabase-auth-helpers/pull/42): fix: update cookie values on request object. diff --git a/examples/nextjs/pages/github-provider-token.tsx b/examples/nextjs/pages/github-provider-token.tsx new file mode 100644 index 00000000..2d83f802 --- /dev/null +++ b/examples/nextjs/pages/github-provider-token.tsx @@ -0,0 +1,52 @@ +// pages/protected-page.js +import { + User, + withAuthRequired, + getUser +} from '@supabase/supabase-auth-helpers/nextjs'; +import Link from 'next/link'; + +export default function ProtectedPage({ + user, + allRepos +}: { + user: User; + allRepos: any; +}) { + return ( + <> +

+ [Home] | [ + withAuthRequired] +

+
Protected content for {user.email}
+

Data fetched with provider token:

+
{JSON.stringify(allRepos, null, 2)}
+

user:

+
{JSON.stringify(user, null, 2)}
+ + ); +} + +export const getServerSideProps = withAuthRequired({ + redirectTo: '/', + async getServerSideProps(ctx) { + // Retrieve provider_token from cookies + const provider_token = ctx.req.cookies['sb-provider-token']; + // Get logged in user's third-party id from metadata + const { user } = await getUser(ctx); + const userId = user?.user_metadata.user_name; + const allRepos = await ( + await fetch( + `https://api.github.com/search/repositories?q=user:${userId}`, + { + method: 'GET', + headers: { + Authorization: `token ${provider_token}` + } + } + ) + ).json(); + return { props: { allRepos, user } }; + } +}); diff --git a/examples/nextjs/pages/index.tsx b/examples/nextjs/pages/index.tsx index f286f75e..473f4cb8 100644 --- a/examples/nextjs/pages/index.tsx +++ b/examples/nextjs/pages/index.tsx @@ -21,10 +21,21 @@ const LoginPage: NextPage = () => { <> {error &&

{error.message}

} {isLoading ?

Loading...

:

Loaded!

} + diff --git a/examples/nextjs/pages/middleware-protected/_middleware.ts b/examples/nextjs/pages/middleware-protected/_middleware.ts new file mode 100644 index 00000000..e8bf7d2f --- /dev/null +++ b/examples/nextjs/pages/middleware-protected/_middleware.ts @@ -0,0 +1,3 @@ +import { withMiddlewareAuthRequired } from '@supabase/supabase-auth-helpers/nextjs'; + +export const middleware = withMiddlewareAuthRequired(); diff --git a/examples/nextjs/pages/middleware-protected/index.tsx b/examples/nextjs/pages/middleware-protected/index.tsx new file mode 100644 index 00000000..c3a70b92 --- /dev/null +++ b/examples/nextjs/pages/middleware-protected/index.tsx @@ -0,0 +1,5 @@ +import type { NextPage } from 'next'; + +const Page: NextPage = () =>

Authenticated

; + +export default Page; diff --git a/src/nextjs/README.md b/src/nextjs/README.md index 562f0cc0..38704d81 100644 --- a/src/nextjs/README.md +++ b/src/nextjs/README.md @@ -193,6 +193,52 @@ export const getServerSideProps = withAuthRequired({ }); ``` +### Server-side data fetching to OAuth APIs using `provider_token` + +When using third-party auth providers, sessions are initiated with an additional `provider_token` field which is persisted as an HTTPOnly cookie upon logging in to enabled usage on the server side. The `provider_token` can be used to make API requests to the OAuth provider's API endpoints on behalf of the logged-in user. In the following example, we fetch the user's full profile from the third-party API during SSR using their id and auth token: + +```js +import { + User, + withAuthRequired, + getUser +} from '@supabase/supabase-auth-helpers/nextjs'; + +interface Profile { + /* ... */ +} + +export default function ProtectedPage({ + user, + data +}: { + user: User, + profile: Profile +}) { + return
Protected content
; +} + +export const getServerSideProps = withAuthRequired({ + redirectTo: '/', + async getServerSideProps(ctx) { + // Retrieve provider_token from cookies + const provider_token = ctx.req.cookies['sb-provider-token']; + // Get logged in user's third-party id from metadata + const { user } = await getUser(ctx); + const userId = user?.user_metadata.provider_id; + const profile: Profile = await ( + await fetch(`https://api.example.com/users/${userId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${provider_token}` + } + }) + ).json(); + return { props: { profile } }; + } +}); +``` + ## Protecting API routes Wrap an API Route to check that the user has a valid session. If they're not logged in the handler will return a @@ -215,3 +261,14 @@ export default withAuthRequired(async function ProtectedRoute(req, res) { ``` If you visit `/api/protected-route` without a valid session cookie, you will get a 401 response. + +## Protecting routes with [Nextjs Middleware](https://nextjs.org/docs/middleware) + +As an alternative to protecting individual routes using `getServerSideProps` with `withAuthRequired`, `withMiddlewareAuthRequired` can be used from inside a `_middleware` file to protect an entire directory. In the following example, all requests to `/protected/*` will check whether a user is signed in, if successful the request will be forwarded to the destination route, otherwise the user will be redirected to `/login` (defaults to: `/`) with a 307 Temporary Redirect response status: + +```ts +// pages/protected/_middleware.ts +import { withMiddlewareAuthRequired } from '@supabase/supabase-auth-helpers/nextjs'; + +export const middleware = withMiddlewareAuthRequired({ redirectTo: '/login' }); +``` diff --git a/src/nextjs/handlers/callback.ts b/src/nextjs/handlers/callback.ts index 13cc9ee1..5fc1803a 100644 --- a/src/nextjs/handlers/callback.ts +++ b/src/nextjs/handlers/callback.ts @@ -11,6 +11,8 @@ export interface HandleCallbackOptions { cookieOptions?: CookieOptions; } +type AuthCookies = Parameters[2]; + export default function handelCallback( req: NextApiRequest, res: NextApiResponse, @@ -31,22 +33,30 @@ export default function handelCallback( new NextResponseAdapter(res), [ { key: 'access-token', value: session.access_token }, - { key: 'refresh-token', value: session.refresh_token } - ].map((token) => ({ - name: `${cookieOptions.name}-${token.key}`, - value: token.value, - domain: cookieOptions.domain, - maxAge: cookieOptions.lifetime ?? 0, - path: cookieOptions.path, - sameSite: cookieOptions.sameSite - })) + { key: 'refresh-token', value: session.refresh_token }, + session.provider_token + ? { key: 'provider-token', value: session.provider_token } + : null + ].reduce((acc, token) => { + if (token) { + acc.push({ + name: `${cookieOptions.name}-${token.key}`, + value: token.value, + domain: cookieOptions.domain, + maxAge: cookieOptions.lifetime ?? 0, + path: cookieOptions.path, + sameSite: cookieOptions.sameSite + }); + } + return acc; + }, []) ); } if (event === 'SIGNED_OUT') { setCookies( new NextRequestAdapter(req), new NextResponseAdapter(res), - ['access-token', 'refresh-token'].map((key) => ({ + ['access-token', 'refresh-token', 'provider-token'].map((key) => ({ name: `${cookieOptions.name}-${key}`, value: '', maxAge: -1 diff --git a/src/nextjs/handlers/logout.ts b/src/nextjs/handlers/logout.ts index b20761cb..d039aae8 100644 --- a/src/nextjs/handlers/logout.ts +++ b/src/nextjs/handlers/logout.ts @@ -32,7 +32,7 @@ export default function handleLogout( setCookies( new NextRequestAdapter(req), new NextResponseAdapter(res), - ['access-token', 'refresh-token'].map((key) => ({ + ['access-token', 'refresh-token', 'provider-token'].map((key) => ({ name: `${cookieOptions.name}-${key}`, value: '', maxAge: -1 diff --git a/src/nextjs/index.ts b/src/nextjs/index.ts index 9d5c024c..e418462d 100644 --- a/src/nextjs/index.ts +++ b/src/nextjs/index.ts @@ -3,6 +3,7 @@ export { User } from '@supabase/supabase-js'; // Methods export * from './handlers'; +export { withMiddlewareAuthRequired } from './utils/withMiddlewareAuthRequired'; export { default as getUser } from './utils/getUser'; export { default as withAuthRequired } from './utils/withAuthRequired'; export { default as supabaseServerClient } from './utils/supabaseServerClient'; diff --git a/src/nextjs/utils/withMiddlewareAuthRequired.ts b/src/nextjs/utils/withMiddlewareAuthRequired.ts new file mode 100644 index 00000000..3b84fb7f --- /dev/null +++ b/src/nextjs/utils/withMiddlewareAuthRequired.ts @@ -0,0 +1,118 @@ +// import { NextResponse } from 'next/server'; TODO fix import +import { NextResponse } from 'next/dist/server/web/spec-extension/response'; +import { NextMiddleware } from 'next/server'; +import { User, ApiError, createClient } from '@supabase/supabase-js'; +import { CookieOptions } from '../types'; +import { COOKIE_OPTIONS } from '../../shared/utils/constants'; +import { jwtDecoder } from '../../shared/utils/jwt'; +import { setCookies } from '../../shared/utils/cookies'; +import { + NextRequestAdapter, + NextResponseAdapter +} from '../../shared/adapters/NextMiddlewareAdapter'; + +export type WithMiddlewareAuthRequired = (options?: { + /** + * Path relative to the site root to redirect an + * unauthenticated visitor. + * + * The original request route will be appended via + * a `redirectedFrom` query parameter, ex: `?redirectedFrom=%2Fdashboard` + */ + redirectTo?: string; + cookieOptions?: CookieOptions; +}) => NextMiddleware; + +export const withMiddlewareAuthRequired: WithMiddlewareAuthRequired = + (options: { redirectTo?: string; cookieOptions?: CookieOptions } = {}) => + async (req) => { + try { + if ( + !process.env.NEXT_PUBLIC_SUPABASE_URL || + !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + ) { + throw new Error( + 'NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY env variables are required!' + ); + } + if (!req.cookies) { + throw new Error('Not able to parse cookies!'); + } + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + { fetch } + ); + const cookieOptions = { ...COOKIE_OPTIONS, ...options.cookieOptions }; + const access_token = req.cookies[`${cookieOptions.name!}-access-token`]; + const refresh_token = req.cookies[`${cookieOptions.name!}-refresh-token`]; + + const res = NextResponse.next(); + + const getUser = async (): Promise<{ + user: User | null; + error: ApiError | null; + }> => { + if (!access_token) { + throw new Error('No cookie found!'); + } + // Get payload from access token. + const jwtUser = jwtDecoder(access_token); + if (!jwtUser?.exp) { + throw new Error('Not able to parse JWT payload!'); + } + const timeNow = Math.round(Date.now() / 1000); + if (jwtUser.exp < timeNow) { + if (!refresh_token) { + throw new Error('No refresh_token cookie found!'); + } + const { data, error } = await supabase.auth.api.refreshAccessToken( + refresh_token + ); + setCookies( + new NextRequestAdapter(req), + new NextResponseAdapter(res), + [ + { key: 'access-token', value: data!.access_token }, + { key: 'refresh-token', value: data!.refresh_token! } + ].map((token) => ({ + name: `${cookieOptions.name}-${token.key}`, + value: token.value, + domain: cookieOptions.domain, + maxAge: cookieOptions.lifetime ?? 0, + path: cookieOptions.path, + sameSite: cookieOptions.sameSite + })) + ); + return { user: data?.user ?? null, error }; + } + return { user: jwtUser, error: null }; + }; + + const authResult = await getUser(); + + if (authResult.error) { + throw new Error( + `Authorization error, redirecting to login page: ${authResult.error.message}` + ); + } else if (!authResult.user) { + throw new Error('No auth user, redirecting'); + } + + // Authentication successful, forward request to protected route + return res; + } catch (err: unknown) { + const { redirectTo = '/' } = options; + if (err instanceof Error) { + console.log( + `Could not authenticate request, redirecting to ${redirectTo}:`, + err + ); + } + const redirectUrl = req.nextUrl.clone(); + redirectUrl.pathname = redirectTo; + redirectUrl.searchParams.set(`redirectedFrom`, req.nextUrl.pathname); + // Authentication failed, redirect request + return NextResponse.redirect(redirectUrl); + } + }; diff --git a/src/shared/adapters/NextMiddlewareAdapter.ts b/src/shared/adapters/NextMiddlewareAdapter.ts new file mode 100644 index 00000000..258600f2 --- /dev/null +++ b/src/shared/adapters/NextMiddlewareAdapter.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { RequestAdapter, ResponseAdapter } from './types'; + +export class NextRequestAdapter implements RequestAdapter { + private req: NextRequest; + constructor(request: NextRequest) { + this.req = request; + } + + setRequestCookie(name: string, value: string) { + this.req.cookies[name] = value; + } + + getHeader(name: string) { + return this.req.headers.get(name); + } +} + +export class NextResponseAdapter implements ResponseAdapter { + private res: NextResponse; + constructor(response: NextResponse) { + this.res = response; + } + + getHeader(name: string) { + return this.res.headers.get(name); + } + + setHeader(name: string, value: string) { + this.res.headers.set(name, value); + return this; + } +}