Skip to content

Commit

Permalink
feat: nextjs: handleAuth
Browse files Browse the repository at this point in the history
  • Loading branch information
thorwebdev committed Feb 9, 2022
1 parent 850066d commit 3fb057a
Show file tree
Hide file tree
Showing 10 changed files with 371 additions and 0 deletions.
3 changes: 3 additions & 0 deletions examples/nextjs/pages/api/auth/[...supabase].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handleAuth } from '@supabase/supabase-auth-helpers/nextjs';

export default handleAuth();
31 changes: 31 additions & 0 deletions src/nextjs/handlers/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CookieOptions } from '../types';
import type { NextApiRequest, NextApiResponse } from 'next';
import handelCallback from './callback';
import handleUser from './user';

export default function handleAuth(
cookieOptions: CookieOptions = {
name: 'sb',
lifetime: 60 * 60 * 8,
domain: '',
path: '/',
sameSite: 'lax'
}
) {
return async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
let {
query: { supabase: route }
} = req;

route = Array.isArray(route) ? route[0] : route;

switch (route) {
case 'callback':
return handelCallback(req, res, cookieOptions);
case 'user':
return handleUser(req, res, cookieOptions);
default:
res.status(404).end();
}
};
}
47 changes: 47 additions & 0 deletions src/nextjs/handlers/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { CookieOptions } from '../types';
import type { NextApiRequest, NextApiResponse } from 'next';
import { setCookies } from '../../shared/utils/cookies';

export default function handelCallback(
req: NextApiRequest,
res: NextApiResponse,
cookieOptions: CookieOptions
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
res.status(405).end('Method Not Allowed');
}
const { event, session } = req.body;

if (!event) throw new Error('Auth event missing!');
if (event === 'SIGNED_IN') {
if (!session) throw new Error('Auth session missing!');
setCookies(
req,
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
}))
);
}
if (event === 'SIGNED_OUT') {
setCookies(
req,
res,
['access-token', 'refresh-token'].map((key) => ({
name: `${cookieOptions.name}-${key}`,
value: '',
maxAge: -1
}))
);
}
res.status(200).json({});
}
1 change: 1 addition & 0 deletions src/nextjs/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as handleAuth } from './auth';
72 changes: 72 additions & 0 deletions src/nextjs/handlers/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { ApiError, CookieOptions } from '../types';
import type { NextApiRequest, NextApiResponse } from 'next';
import { setCookies } from '../../shared/utils/cookies';
import { createClient } from '@supabase/supabase-js';

export default async function handleUser(
req: NextApiRequest,
res: NextApiResponse,
cookieOptions: CookieOptions
) {
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
);
const access_token = req.cookies[`${cookieOptions.name}-access-token`];
const refresh_token = req.cookies[`${cookieOptions.name}-refresh-token`];

if (!access_token) {
throw new Error('No cookie found!');
}

const { user, error: getUserError } = await supabase.auth.api.getUser(
access_token
);
if (getUserError) {
if (!refresh_token) throw new Error('No refresh_token cookie found!');
if (!res)
throw new Error(
'You need to pass the res object to automatically refresh the session!'
);
const { data, error } = await supabase.auth.api.refreshAccessToken(
refresh_token
);
if (error) {
throw error;
} else if (data) {
setCookies(
req,
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
}))
);
res.status(200).json(user);
}
}
res.status(200).json(user);
} catch (e) {
const error = e as ApiError;
res.status(400).json({ error: error.message });
}
}
1 change: 1 addition & 0 deletions src/nextjs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './handlers';
Empty file removed src/nextjs/index.tsx
Empty file.
16 changes: 16 additions & 0 deletions src/nextjs/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface CookieOptions {
// (Optional) The Cookie name prefix. Defaults to `sb` meaning the cookies will be `sb-access-token` and `sb-refresh-token`.
name?: string;
// (Optional) The cookie lifetime (expiration) in seconds. Set to 8 hours by default.
lifetime?: number;
// (Optional) The cookie domain this should run on. Leave it blank to restrict it to your domain.
domain?: string;
path?: string;
// (Optional) SameSite configuration for the session cookie. Defaults to 'lax', but can be changed to 'strict' or 'none'. Set it to false if you want to disable the SameSite setting.
sameSite?: string;
}

export interface ApiError {
message: string;
status: number;
}
9 changes: 9 additions & 0 deletions src/react/components/UserProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ export const UserProvider = (props: Props) => {
}
}, [profileUrl]);

useEffect(() => {
async function init() {
setIsLoading(true);
await checkSession();
setIsLoading(false);
}
init();
}, []);

useEffect(() => {
const { data: authListener } = supabaseClient.auth.onAuthStateChange(
async (event, session) => {
Expand Down
191 changes: 191 additions & 0 deletions src/shared/utils/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
type Cookie = {
name: string;
value: string;
maxAge: number;
domain?: string;
path?: string;
sameSite?: string;
};

/**
* Serialize data into a cookie header.
*/
function serialize(
name: string,
val: string,
options: {
maxAge: number;
domain: string;
path: string;
expires: Date;
httpOnly: boolean;
secure: boolean;
sameSite: string;
}
) {
const opt = options || {};
const enc = encodeURIComponent;
/* eslint-disable-next-line no-control-regex */
const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;

if (typeof enc !== 'function') {
throw new TypeError('option encode is invalid');
}

if (!fieldContentRegExp.test(name)) {
throw new TypeError('argument name is invalid');
}

const value = enc(val);

if (value && !fieldContentRegExp.test(value)) {
throw new TypeError('argument val is invalid');
}

let str = name + '=' + value;

if (null != opt.maxAge) {
const maxAge = opt.maxAge - 0;

if (isNaN(maxAge) || !isFinite(maxAge)) {
throw new TypeError('option maxAge is invalid');
}

str += '; Max-Age=' + Math.floor(maxAge);
}

if (opt.domain) {
if (!fieldContentRegExp.test(opt.domain)) {
throw new TypeError('option domain is invalid');
}

str += '; Domain=' + opt.domain;
}

if (opt.path) {
if (!fieldContentRegExp.test(opt.path)) {
throw new TypeError('option path is invalid');
}

str += '; Path=' + opt.path;
}

if (opt.expires) {
if (typeof opt.expires.toUTCString !== 'function') {
throw new TypeError('option expires is invalid');
}

str += '; Expires=' + opt.expires.toUTCString();
}

if (opt.httpOnly) {
str += '; HttpOnly';
}

if (opt.secure) {
str += '; Secure';
}

if (opt.sameSite) {
const sameSite =
typeof opt.sameSite === 'string'
? opt.sameSite.toLowerCase()
: opt.sameSite;

switch (sameSite) {
case 'lax':
str += '; SameSite=Lax';
break;
case 'strict':
str += '; SameSite=Strict';
break;
case 'none':
str += '; SameSite=None';
break;
default:
throw new TypeError('option sameSite is invalid');
}
}

return str;
}

/**
* Based on the environment and the request we know if a secure cookie can be set.
*/
function isSecureEnvironment(req: any) {
if (!req || !req.headers || !req.headers.host) {
throw new Error('The "host" request header is not available');
}

const host =
(req.headers.host.indexOf(':') > -1 && req.headers.host.split(':')[0]) ||
req.headers.host;
if (
['localhost', '127.0.0.1'].indexOf(host) > -1 ||
host.endsWith('.local')
) {
return false;
}

return true;
}

/**
* Serialize a cookie to a string.
*/
function serializeCookie(cookie: Cookie, secure: boolean) {
return serialize(cookie.name, cookie.value, {
maxAge: cookie.maxAge,
expires: new Date(Date.now() + cookie.maxAge * 1000),
httpOnly: true,
secure,
path: cookie.path ?? '/',
domain: cookie.domain ?? '',
sameSite: cookie.sameSite ?? 'lax'
});
}

/**
* Get Cookie Header strings.
*/
export function getCookieString(
req: any,
res: any,
cookies: Array<Cookie>
): string[] {
const strCookies = cookies.map((c) =>
serializeCookie(c, isSecureEnvironment(req))
);
const previousCookies = res.getHeader('Set-Cookie');
if (previousCookies) {
if (previousCookies instanceof Array) {
Array.prototype.push.apply(strCookies, previousCookies);
} else if (typeof previousCookies === 'string') {
strCookies.push(previousCookies);
}
}
return strCookies;
}

/**
* Set one or more cookies.
*/
export function setCookies(req: any, res: any, cookies: Array<Cookie>) {
res.setHeader('Set-Cookie', getCookieString(req, res, cookies));
}

/**
* Set one or more cookies.
*/
export function setCookie(req: any, res: any, cookie: Cookie) {
setCookies(req, res, [cookie]);
}

export function deleteCookie(req: any, res: any, name: string) {
setCookie(req, res, {
name,
value: '',
maxAge: -1
});
}

0 comments on commit 3fb057a

Please sign in to comment.