Skip to content

Commit

Permalink
feat: 1.3.0 release (#47)
Browse files Browse the repository at this point in the history
- [#44](#43): feat: Add `withMiddlewareAuthRequired` Nextjs Middleware util to protect directories.
- [#43](#43): feat: store `provider_token` in cookie.
  • Loading branch information
thorwebdev authored Mar 22, 2022
1 parent e188cb9 commit 39c8a06
Show file tree
Hide file tree
Showing 11 changed files with 307 additions and 11 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
52 changes: 52 additions & 0 deletions examples/nextjs/pages/github-provider-token.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p>
[<Link href="/">Home</Link>] | [
<Link href="/profile">withAuthRequired</Link>]
</p>
<div>Protected content for {user.email}</div>
<p>Data fetched with provider token:</p>
<pre>{JSON.stringify(allRepos, null, 2)}</pre>
<p>user:</p>
<pre>{JSON.stringify(user, null, 2)}</pre>
</>
);
}

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 } };
}
});
11 changes: 11 additions & 0 deletions examples/nextjs/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,21 @@ const LoginPage: NextPage = () => {
<>
{error && <p>{error.message}</p>}
{isLoading ? <h1>Loading...</h1> : <h1>Loaded!</h1>}
<button
onClick={() => {
supabaseClient.auth.signIn(
{ provider: 'github' },
{ scopes: 'repo' }
);
}}
>
GitHub wit scopes
</button>
<Auth
// view="update_password"
supabaseClient={supabaseClient}
providers={['google', 'github']}
// scopes={{github: 'repo'}} // TODO: enable scopes in Auth component.
socialLayout="horizontal"
socialButtonSize="xlarge"
/>
Expand Down
3 changes: 3 additions & 0 deletions examples/nextjs/pages/middleware-protected/_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { withMiddlewareAuthRequired } from '@supabase/supabase-auth-helpers/nextjs';

export const middleware = withMiddlewareAuthRequired();
5 changes: 5 additions & 0 deletions examples/nextjs/pages/middleware-protected/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { NextPage } from 'next';

const Page: NextPage = () => <p>Authenticated</p>;

export default Page;
57 changes: 57 additions & 0 deletions src/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>Protected content</div>;
}

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
Expand All @@ -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' });
```
30 changes: 20 additions & 10 deletions src/nextjs/handlers/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface HandleCallbackOptions {
cookieOptions?: CookieOptions;
}

type AuthCookies = Parameters<typeof setCookies>[2];

export default function handelCallback(
req: NextApiRequest,
res: NextApiResponse,
Expand All @@ -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<AuthCookies>((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
Expand Down
2 changes: 1 addition & 1 deletion src/nextjs/handlers/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/nextjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
118 changes: 118 additions & 0 deletions src/nextjs/utils/withMiddlewareAuthRequired.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
34 changes: 34 additions & 0 deletions src/shared/adapters/NextMiddlewareAdapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}

0 comments on commit 39c8a06

Please sign in to comment.