From 91adb8661dd350e37e40b15eee1f23a9d413843b Mon Sep 17 00:00:00 2001 From: Sean Li Date: Wed, 22 Apr 2020 16:15:12 -0700 Subject: [PATCH] Add example with Magic authentication (#11810) * Add example with Magic and Passport.js * Tweaked wording on README * Fixed lint error * Fixed prettier error * Update examples/magic/README.md Removed Download manually section from README Co-Authored-By: Joe Haddad * Removed dependency on passport and express + cleanup * Changed ZEIT brand to Vercel * Updated readme instructions and secrets * Renamed example * Changed db comment Co-authored-by: Joe Haddad Co-authored-by: Luis Alvarez --- examples/with-magic/.env.example | 2 + examples/with-magic/.gitignore | 2 + examples/with-magic/README.md | 53 ++++++++++++++++++ examples/with-magic/components/form.js | 56 +++++++++++++++++++ examples/with-magic/components/header.js | 67 +++++++++++++++++++++++ examples/with-magic/components/layout.js | 57 +++++++++++++++++++ examples/with-magic/lib/auth-cookies.js | 39 +++++++++++++ examples/with-magic/lib/hooks.js | 31 +++++++++++ examples/with-magic/lib/iron.js | 14 +++++ examples/with-magic/lib/magic.js | 3 + examples/with-magic/next.config.js | 16 ++++++ examples/with-magic/now.json | 12 ++++ examples/with-magic/package.json | 21 +++++++ examples/with-magic/pages/api/login.js | 17 ++++++ examples/with-magic/pages/api/logout.js | 11 ++++ examples/with-magic/pages/api/user.js | 9 +++ examples/with-magic/pages/index.js | 53 ++++++++++++++++++ examples/with-magic/pages/login.js | 65 ++++++++++++++++++++++ examples/with-magic/pages/profile.js | 20 +++++++ examples/with-magic/public/favicon.ico | Bin 0 -> 15086 bytes examples/with-magic/public/vercel.svg | 1 + 21 files changed, 549 insertions(+) create mode 100644 examples/with-magic/.env.example create mode 100644 examples/with-magic/.gitignore create mode 100644 examples/with-magic/README.md create mode 100644 examples/with-magic/components/form.js create mode 100644 examples/with-magic/components/header.js create mode 100644 examples/with-magic/components/layout.js create mode 100644 examples/with-magic/lib/auth-cookies.js create mode 100644 examples/with-magic/lib/hooks.js create mode 100644 examples/with-magic/lib/iron.js create mode 100644 examples/with-magic/lib/magic.js create mode 100644 examples/with-magic/next.config.js create mode 100644 examples/with-magic/now.json create mode 100644 examples/with-magic/package.json create mode 100644 examples/with-magic/pages/api/login.js create mode 100644 examples/with-magic/pages/api/logout.js create mode 100644 examples/with-magic/pages/api/user.js create mode 100644 examples/with-magic/pages/index.js create mode 100644 examples/with-magic/pages/login.js create mode 100644 examples/with-magic/pages/profile.js create mode 100644 examples/with-magic/public/favicon.ico create mode 100644 examples/with-magic/public/vercel.svg diff --git a/examples/with-magic/.env.example b/examples/with-magic/.env.example new file mode 100644 index 0000000000000..3c4727b6476cf --- /dev/null +++ b/examples/with-magic/.env.example @@ -0,0 +1,2 @@ +MAGIC_PUBLISHABLE_KEY= +MAGIC_SECRET_KEY= diff --git a/examples/with-magic/.gitignore b/examples/with-magic/.gitignore new file mode 100644 index 0000000000000..1e31d4e669ee7 --- /dev/null +++ b/examples/with-magic/.gitignore @@ -0,0 +1,2 @@ +.env +.now diff --git a/examples/with-magic/README.md b/examples/with-magic/README.md new file mode 100644 index 0000000000000..9b14075977e1a --- /dev/null +++ b/examples/with-magic/README.md @@ -0,0 +1,53 @@ +# Magic Example + +This example show how to use [Magic](https://magic.link) with Next.js. The example features cookie-based, passwordless authentication with email-based magic links. + +The example shows how to do a login and logout; and to get the user info using a hook with [SWR](https://swr.now.sh). + +A DB is not included. But you can add any DB you like!. + +The login cookie is `httpOnly`, meaning it can only be accessed by the API, and it's encrypted using [@hapi/iron](https://hapi.dev/family/iron) for more security. + +## Deploy your own + +Deploy the example using [Vercel Now](https://vercel.com/docs/now-cli#commands/overview/basic-usage): + +[![Deploy with Vercel Now](https://vercel.com/button)](https://vercel.com/new/project?template=https://github.com/zeit/next.js/tree/canary/examples/with-magic) + +## How to use + +### Using `create-next-app` + +Execute [`create-next-app`](https://github.com/zeit/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npm init next-app --example with-magic with-magic-app +# or +yarn create next-app --example with-magic with-magic-app +``` + +## Configuration + +Login to the [Magic Dashboard](https://dashboard.magic.link/) and get the keys of your application + +![Magic Dashboard](https://gblobscdn.gitbook.com/assets%2F-M1XNjqusnKyXZc7t7qQ%2F-M3HsSftOAghkNs-ttU3%2F-M3HsllfdwdDmeFXBK3U%2Fdashboard-pk.png?alt=media&token=4d6e7543-ae20-4355-951c-c6421b8f1b5f) + +Next, copy the `.env.example` file in this directory to .env (which will be ignored by Git): + +```bash +cp .env.example .env +``` + +Then set each variable on `.env`: + +- `MAGIC_PUBLISHABLE_KEY` should look like `pk_test_abc` or `pk_live_ABC` +- `MAGIC_SECRET_KEY` should look like `sk_test_ABC` or `sk_live_ABC` + +To deploy on Vercel, you need to set the environment variables with **Now Secrets** using [Now CLI](https://vercel.com/download) ([Documentation](https://vercel.com/docs/now-cli#commands/secrets)). + +Install [Now CLI](https://vercel.com/download), log in to your account from the CLI, and run the following commands to add the environment variables. Replace `` and `` with the corresponding strings in `.env`: + +```bash +now secrets add next_example_magic_publishable_key +now secrets add next_example_magic_secret_key +``` diff --git a/examples/with-magic/components/form.js b/examples/with-magic/components/form.js new file mode 100644 index 0000000000000..112cae74af4a2 --- /dev/null +++ b/examples/with-magic/components/form.js @@ -0,0 +1,56 @@ +const Form = ({ errorMessage, onSubmit }) => ( +
+ + +
+ +
+ + {errorMessage &&

{errorMessage}

} + + +
+) + +export default Form diff --git a/examples/with-magic/components/header.js b/examples/with-magic/components/header.js new file mode 100644 index 0000000000000..0b605d656739a --- /dev/null +++ b/examples/with-magic/components/header.js @@ -0,0 +1,67 @@ +import Link from 'next/link' +import { useUser } from '../lib/hooks' + +const Header = () => { + const user = useUser() + + return ( +
+ + +
+ ) +} + +export default Header diff --git a/examples/with-magic/components/layout.js b/examples/with-magic/components/layout.js new file mode 100644 index 0000000000000..9e3c27da18502 --- /dev/null +++ b/examples/with-magic/components/layout.js @@ -0,0 +1,57 @@ +import Head from 'next/head' +import Header from './header' + +const Layout = props => ( + <> + + Magic + + + +
+ +
+
{props.children}
+
+ + + + + +) + +export default Layout diff --git a/examples/with-magic/lib/auth-cookies.js b/examples/with-magic/lib/auth-cookies.js new file mode 100644 index 0000000000000..0ae93e7523988 --- /dev/null +++ b/examples/with-magic/lib/auth-cookies.js @@ -0,0 +1,39 @@ +import { serialize, parse } from 'cookie' + +const TOKEN_NAME = 'token' +const MAX_AGE = 60 * 60 * 8 // 8 hours + +export function setTokenCookie(res, token) { + const cookie = serialize(TOKEN_NAME, token, { + maxAge: MAX_AGE, + expires: new Date(Date.now() + MAX_AGE * 1000), + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }) + res.setHeader('Set-Cookie', cookie) +} + +export function removeTokenCookie(res) { + const cookie = serialize(TOKEN_NAME, '', { + maxAge: -1, + path: '/', + }) + + res.setHeader('Set-Cookie', cookie) +} + +export function parseCookies(req) { + // For API Routes we don't need to parse the cookies. + if (req.cookies) return req.cookies + + // For pages we do need to parse the cookies. + const cookie = req.headers?.cookie + return parse(cookie || '') +} + +export function getTokenCookie(req) { + const cookies = parseCookies(req) + return cookies[TOKEN_NAME] +} diff --git a/examples/with-magic/lib/hooks.js b/examples/with-magic/lib/hooks.js new file mode 100644 index 0000000000000..645364466d3c0 --- /dev/null +++ b/examples/with-magic/lib/hooks.js @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import Router from 'next/router' +import useSWR from 'swr' + +const fetcher = url => + fetch(url) + .then(r => r.json()) + .then(data => { + return { user: data?.user || null } + }) + +export function useUser({ redirectTo, redirectIfFound } = {}) { + const { data, error } = useSWR('/api/user', fetcher) + const user = data?.user + const finished = Boolean(data) + const hasUser = Boolean(user) + + useEffect(() => { + if (!redirectTo || !finished) return + if ( + // If redirectTo is set, redirect if the user was not found. + (redirectTo && !redirectIfFound && !hasUser) || + // If redirectIfFound is also set, redirect if the user was found + (redirectIfFound && hasUser) + ) { + Router.push(redirectTo) + } + }, [redirectTo, redirectIfFound, finished, hasUser]) + + return error ? null : user +} diff --git a/examples/with-magic/lib/iron.js b/examples/with-magic/lib/iron.js new file mode 100644 index 0000000000000..977c4b110dd99 --- /dev/null +++ b/examples/with-magic/lib/iron.js @@ -0,0 +1,14 @@ +import Iron from '@hapi/iron' +import { getTokenCookie } from './auth-cookies' + +// Use an environment variable here instead of a hardcoded value for production +const TOKEN_SECRET = 'this-is-a-secret-value-with-at-least-32-characters' + +export function encryptSession(session) { + return Iron.seal(session, TOKEN_SECRET, Iron.defaults) +} + +export async function getSession(req) { + const token = getTokenCookie(req) + return token && Iron.unseal(token, TOKEN_SECRET, Iron.defaults) +} diff --git a/examples/with-magic/lib/magic.js b/examples/with-magic/lib/magic.js new file mode 100644 index 0000000000000..f09937d4ecdde --- /dev/null +++ b/examples/with-magic/lib/magic.js @@ -0,0 +1,3 @@ +const { Magic } = require('@magic-sdk/admin') + +export const magic = new Magic(process.env.MAGIC_SECRET_KEY) diff --git a/examples/with-magic/next.config.js b/examples/with-magic/next.config.js new file mode 100644 index 0000000000000..240431e663177 --- /dev/null +++ b/examples/with-magic/next.config.js @@ -0,0 +1,16 @@ +const nextEnv = require('next-env') +const dotenvLoad = require('dotenv-load') + +dotenvLoad() + +const withNextEnv = nextEnv() + +module.exports = withNextEnv({ + env: { + MAGIC_PUBLISHABLE_KEY: process.env.MAGIC_PUBLISHABLE_KEY, + MAGIC_SECRET_KEY: process.env.MAGIC_SECRET_KEY, + }, + devIndicators: { + autoPrerender: false, + }, +}) diff --git a/examples/with-magic/now.json b/examples/with-magic/now.json new file mode 100644 index 0000000000000..e8abee20ef0c4 --- /dev/null +++ b/examples/with-magic/now.json @@ -0,0 +1,12 @@ +{ + "env": { + "MAGIC_PUBLISHABLE_KEY": "@next_example_magic_publishable_key", + "MAGIC_SECRET_KEY": "@next_example_magic_secret_key" + }, + "build": { + "env": { + "MAGIC_PUBLISHABLE_KEY": "@next_example_magic_publishable_key", + "MAGIC_SECRET_KEY": "@next_example_magic_secret_key" + } + } +} diff --git a/examples/with-magic/package.json b/examples/with-magic/package.json new file mode 100644 index 0000000000000..6e6944abed535 --- /dev/null +++ b/examples/with-magic/package.json @@ -0,0 +1,21 @@ +{ + "name": "with-magic", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@hapi/iron": "6.0.0", + "@magic-sdk/admin": "1.0.0", + "cookie": "0.4.0", + "dotenv-load": "^2.0.0", + "magic-sdk": "1.0.1", + "next": "latest", + "next-env": "^1.1.1", + "react": "latest", + "react-dom": "latest", + "swr": "0.1.16" + }, + "license": "ISC" +} diff --git a/examples/with-magic/pages/api/login.js b/examples/with-magic/pages/api/login.js new file mode 100644 index 0000000000000..1e1b11dff9b6b --- /dev/null +++ b/examples/with-magic/pages/api/login.js @@ -0,0 +1,17 @@ +import { magic } from '../../lib/magic' +import { encryptSession } from '../../lib/iron' +import { setTokenCookie } from '../../lib/auth-cookies' + +export default async function login(req, res) { + try { + const didToken = req.headers.authorization.substr(7) + const metadata = await magic.users.getMetadataByToken(didToken) + const session = { ...metadata } + // The token is a string with the encrypted session + const token = await encryptSession(session) + setTokenCookie(res, token) + res.status(200).send({ done: true }) + } catch (error) { + res.status(error.status || 500).end(error.message) + } +} diff --git a/examples/with-magic/pages/api/logout.js b/examples/with-magic/pages/api/logout.js new file mode 100644 index 0000000000000..d29a38f6c41d0 --- /dev/null +++ b/examples/with-magic/pages/api/logout.js @@ -0,0 +1,11 @@ +import { magic } from '../../lib/magic' +import { removeTokenCookie } from '../../lib/auth-cookies' +import { getSession } from '../../lib/iron' + +export default async function logout(req, res) { + const session = await getSession(req) + await magic.users.logoutByIssuer(session.issuer) + removeTokenCookie(res) + res.writeHead(302, { Location: '/' }) + res.end() +} diff --git a/examples/with-magic/pages/api/user.js b/examples/with-magic/pages/api/user.js new file mode 100644 index 0000000000000..dad216c76e9a3 --- /dev/null +++ b/examples/with-magic/pages/api/user.js @@ -0,0 +1,9 @@ +import { getSession } from '../../lib/iron' + +export default async function user(req, res) { + const session = await getSession(req) + // After getting the session you may want to fetch for the user instead + // of sending the session's payload directly, this example doesn't have a DB + // so it won't matter in this case + res.status(200).json({ user: session || null }) +} diff --git a/examples/with-magic/pages/index.js b/examples/with-magic/pages/index.js new file mode 100644 index 0000000000000..9009f76a4e3d7 --- /dev/null +++ b/examples/with-magic/pages/index.js @@ -0,0 +1,53 @@ +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' + +const Home = () => { + const user = useUser() + + return ( + +

Magic Example

+ +

Steps to test this authentication example:

+ +
    +
  1. Click Login and enter an email.
  2. +
  3. + You'll be redirected to Home. Click on Profile, notice how your + session is being used through a token stored in a cookie. +
  4. +
  5. + Click Logout and try to go to Profile again. You'll get redirected to + Login. +
  6. +
+ +

+ To learn more about Magic, visit their{' '} + + documentation + + . +

+ + {user && ( + <> +

Currently logged in as:

+
{JSON.stringify(user, null, 2)}
+ + )} + + +
+ ) +} + +export default Home diff --git a/examples/with-magic/pages/login.js b/examples/with-magic/pages/login.js new file mode 100644 index 0000000000000..24ad5d9ff6319 --- /dev/null +++ b/examples/with-magic/pages/login.js @@ -0,0 +1,65 @@ +import { useState } from 'react' +import Router from 'next/router' +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' +import Form from '../components/form' + +import { Magic } from 'magic-sdk' + +const Login = () => { + useUser({ redirectTo: '/', redirectIfFound: true }) + + const [errorMsg, setErrorMsg] = useState('') + + async function handleSubmit(e) { + event.preventDefault() + + if (errorMsg) setErrorMsg('') + + const body = { + email: e.currentTarget.email.value, + } + + try { + const magic = new Magic(process.env.MAGIC_PUBLISHABLE_KEY) + const didToken = await magic.auth.loginWithMagicLink({ + email: body.email, + }) + const res = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + didToken, + }, + body: JSON.stringify(body), + }) + if (res.status === 200) { + Router.push('/') + } else { + throw new Error(await res.text()) + } + } catch (error) { + console.error('An unexpected error happened occurred:', error) + setErrorMsg(error.message) + } + } + + return ( + +
+
+
+ +
+ ) +} + +export default Login diff --git a/examples/with-magic/pages/profile.js b/examples/with-magic/pages/profile.js new file mode 100644 index 0000000000000..aec3ae9dcb7ec --- /dev/null +++ b/examples/with-magic/pages/profile.js @@ -0,0 +1,20 @@ +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' + +const Profile = () => { + const user = useUser({ redirectTo: '/login' }) + + return ( + +

Profile

+ {user && ( + <> +

Your session:

+
{JSON.stringify(user, null, 2)}
+ + )} +
+ ) +} + +export default Profile diff --git a/examples/with-magic/public/favicon.ico b/examples/with-magic/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4965832f2c9b0605eaa189b7c7fb11124d24e48a GIT binary patch literal 15086 zcmeHOOH5Q(7(R0cc?bh2AT>N@1PWL!LLfZKyG5c!MTHoP7_p!sBz0k$?pjS;^lmgJ zU6^i~bWuZYHL)9$wuvEKm~qo~(5=Lvx5&Hv;?X#m}i|`yaGY4gX+&b>tew;gcnRQA1kp zBbm04SRuuE{Hn+&1wk%&g;?wja_Is#1gKoFlI7f`Gt}X*-nsMO30b_J@)EFNhzd1QM zdH&qFb9PVqQOx@clvc#KAu}^GrN`q5oP(8>m4UOcp`k&xwzkTio*p?kI4BPtIwX%B zJN69cGsm=x90<;Wmh-bs>43F}ro$}Of@8)4KHndLiR$nW?*{Rl72JPUqRr3ta6e#A z%DTEbi9N}+xPtd1juj8;(CJt3r9NOgb>KTuK|z7!JB_KsFW3(pBN4oh&M&}Nb$Ee2 z$-arA6a)CdsPj`M#1DS>fqj#KF%0q?w50GN4YbmMZIoF{e1yTR=4ablqXHBB2!`wM z1M1ke9+<);|AI;f=2^F1;G6Wfpql?1d5D4rMr?#f(=hkoH)U`6Gb)#xDLjoKjp)1;Js@2Iy5yk zMXUqj+gyk1i0yLjWS|3sM2-1ECc;MAz<4t0P53%7se$$+5Ex`L5TQO_MMXXi04UDIU+3*7Ez&X|mj9cFYBXqM{M;mw_ zpw>azP*qjMyNSD4hh)XZt$gqf8f?eRSFX8VQ4Y+H3jAtvyTrXr`qHAD6`m;aYmH2zOhJC~_*AuT} zvUxC38|JYN94i(05R)dVKgUQF$}#cxV7xZ4FULqFCNX*Forhgp*yr6;DsIk=ub0Hv zpk2L{9Q&|uI^b<6@i(Y+iSxeO_n**4nRLc`P!3ld5jL=nZRw6;DEJ*1z6Pvg+eW|$lnnjO zjd|8>6l{i~UxI244CGn2kK@cJ|#ecwgSyt&HKA2)z zrOO{op^o*-