From bb200e585afe14a93379bbf46e1dc144d314352b Mon Sep 17 00:00:00 2001 From: Sean Li Date: Fri, 10 Apr 2020 08:21:56 -0700 Subject: [PATCH 01/10] Add example with Magic and Passport.js --- examples/magic/.env.template | 2 + examples/magic/.gitignore | 3 ++ examples/magic/README.md | 61 ++++++++++++++++++++++++++ examples/magic/components/form.js | 58 +++++++++++++++++++++++++ examples/magic/components/header.js | 67 +++++++++++++++++++++++++++++ examples/magic/components/layout.js | 38 ++++++++++++++++ examples/magic/lib/auth-cookies.js | 39 +++++++++++++++++ examples/magic/lib/hooks.js | 31 +++++++++++++ examples/magic/lib/iron.js | 14 ++++++ examples/magic/lib/magic.js | 18 ++++++++ examples/magic/lib/user.js | 14 ++++++ examples/magic/next.config.js | 16 +++++++ examples/magic/now.json | 12 ++++++ examples/magic/package.json | 24 +++++++++++ examples/magic/pages/api/login.js | 40 +++++++++++++++++ examples/magic/pages/api/logout.js | 13 ++++++ examples/magic/pages/api/user.js | 9 ++++ examples/magic/pages/index.js | 36 ++++++++++++++++ examples/magic/pages/login.js | 63 +++++++++++++++++++++++++++ examples/magic/pages/profile.js | 15 +++++++ 20 files changed, 573 insertions(+) create mode 100644 examples/magic/.env.template create mode 100644 examples/magic/.gitignore create mode 100644 examples/magic/README.md create mode 100644 examples/magic/components/form.js create mode 100644 examples/magic/components/header.js create mode 100644 examples/magic/components/layout.js create mode 100644 examples/magic/lib/auth-cookies.js create mode 100644 examples/magic/lib/hooks.js create mode 100644 examples/magic/lib/iron.js create mode 100644 examples/magic/lib/magic.js create mode 100644 examples/magic/lib/user.js create mode 100644 examples/magic/next.config.js create mode 100644 examples/magic/now.json create mode 100644 examples/magic/package.json create mode 100644 examples/magic/pages/api/login.js create mode 100644 examples/magic/pages/api/logout.js create mode 100644 examples/magic/pages/api/user.js create mode 100644 examples/magic/pages/index.js create mode 100644 examples/magic/pages/login.js create mode 100644 examples/magic/pages/profile.js diff --git a/examples/magic/.env.template b/examples/magic/.env.template new file mode 100644 index 0000000000000..3c4727b6476cf --- /dev/null +++ b/examples/magic/.env.template @@ -0,0 +1,2 @@ +MAGIC_PUBLISHABLE_KEY= +MAGIC_SECRET_KEY= diff --git a/examples/magic/.gitignore b/examples/magic/.gitignore new file mode 100644 index 0000000000000..3c97ab2f03f9d --- /dev/null +++ b/examples/magic/.gitignore @@ -0,0 +1,3 @@ +.env + +.now diff --git a/examples/magic/README.md b/examples/magic/README.md new file mode 100644 index 0000000000000..881a088d1501d --- /dev/null +++ b/examples/magic/README.md @@ -0,0 +1,61 @@ +# 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. You can use any db you want and add it [here](/lib/user.js). + +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. + +## Configure Magic + +Login to the [Magic Dashboard](https://dashboard.magic.link/) and add new application to get your keys. Don't forget to put keys in `.env` (look for `.env.template` for example) and upload them as secrets to [ZEIT Now](https://zeit.co/now). + +``` +now secrets add @magic-publishable-key pk_test_********* +``` +``` +now secrets add @magic-secret-key sk_test_********* +``` + +![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) + +## Deploy your own + +Deploy the example using [ZEIT Now](https://zeit.co/now): + +[![Deploy with ZEIT Now](https://zeit.co/button)](https://zeit.co/new/project?template=https://github.com/zeit/next.js/tree/canary/examples/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 magic magic-app +# or +yarn create next-app --example magic magic-app +``` + +### Download manually + +Download the example [or clone the repo](https://github.com/zeit/next.js): + +```bash +curl https://codeload.github.com/zeit/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/magic +cd magic +``` + +Install it and run: + +```bash +npm install +npm run dev +# or +yarn +yarn dev +``` + +Deploy it to the cloud with [ZEIT Now](https://zeit.co/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/magic/components/form.js b/examples/magic/components/form.js new file mode 100644 index 0000000000000..739530757061f --- /dev/null +++ b/examples/magic/components/form.js @@ -0,0 +1,58 @@ +import Link from 'next/link' + +const Form = ({ errorMessage, onSubmit }) => ( +
+ + +
+ +
+ + {errorMessage &&

{errorMessage}

} + + +
+) + +export default Form diff --git a/examples/magic/components/header.js b/examples/magic/components/header.js new file mode 100644 index 0000000000000..0b605d656739a --- /dev/null +++ b/examples/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/magic/components/layout.js b/examples/magic/components/layout.js new file mode 100644 index 0000000000000..107d457280a25 --- /dev/null +++ b/examples/magic/components/layout.js @@ -0,0 +1,38 @@ +import Head from 'next/head' +import Header from './header' + +const Layout = props => ( + <> + + Magic + + +
+ +
+
{props.children}
+
+ + + +) + +export default Layout diff --git a/examples/magic/lib/auth-cookies.js b/examples/magic/lib/auth-cookies.js new file mode 100644 index 0000000000000..0ae93e7523988 --- /dev/null +++ b/examples/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/magic/lib/hooks.js b/examples/magic/lib/hooks.js new file mode 100644 index 0000000000000..645364466d3c0 --- /dev/null +++ b/examples/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/magic/lib/iron.js b/examples/magic/lib/iron.js new file mode 100644 index 0000000000000..977c4b110dd99 --- /dev/null +++ b/examples/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/magic/lib/magic.js b/examples/magic/lib/magic.js new file mode 100644 index 0000000000000..6a361e7d4bfbd --- /dev/null +++ b/examples/magic/lib/magic.js @@ -0,0 +1,18 @@ +import { findUser } from './user' + +const { Magic } = require("@magic-sdk/admin") +const magic = new Magic(process.env.MAGIC_SECRET_KEY) + +const MagicStrategy = require("passport-magic").Strategy + +export const strategy = new MagicStrategy(async function(user, done) { + const userMetadata = await magic.users.getMetadataByIssuer(user.issuer) + // In real application, if existing user doesn't exist, create new user based on email + findUser({ email: userMetadata.email, issuer: userMetadata.issuer }) + .then(user => { + done(null, user) + }) + .catch(error => { + done(error) + }) +}) diff --git a/examples/magic/lib/user.js b/examples/magic/lib/user.js new file mode 100644 index 0000000000000..c5381b14d04a2 --- /dev/null +++ b/examples/magic/lib/user.js @@ -0,0 +1,14 @@ +// import crypto from 'crypto' + +/** + * User methods. The example doesn't contain a DB, but for real applications you must use a + * db here, such as MongoDB, Fauna, SQL, etc. + */ + +export async function findUser({ email, issuer }) { + // Here you should lookup for the user in your DB and compare the email: + // + // const user = await DB.findUser(...) + + return { email, issuer, createdAt: Date.now() } +} diff --git a/examples/magic/next.config.js b/examples/magic/next.config.js new file mode 100644 index 0000000000000..bbe65ee561f95 --- /dev/null +++ b/examples/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/magic/now.json b/examples/magic/now.json new file mode 100644 index 0000000000000..99836df89092d --- /dev/null +++ b/examples/magic/now.json @@ -0,0 +1,12 @@ +{ + "env": { + "MAGIC_PUBLISHABLE_KEY": "@magic-publishable-key", + "MAGIC_SECRET_KEY": "@magic-secret-key" + }, + "build": { + "env": { + "MAGIC_PUBLISHABLE_KEY": "@magic-publishable-key", + "MAGIC_SECRET_KEY": "@magic-secret-key" + } + } +} diff --git a/examples/magic/package.json b/examples/magic/package.json new file mode 100644 index 0000000000000..5f77ab8c4b49f --- /dev/null +++ b/examples/magic/package.json @@ -0,0 +1,24 @@ +{ + "name": "magic-next", + "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", + "express": "4.17.1", + "magic-sdk": "1.0.1", + "next": "latest", + "next-env": "^1.1.1", + "passport": "0.4.1", + "passport-magic": "1.0.0", + "react": "latest", + "react-dom": "latest", + "swr": "0.1.16" + }, + "license": "ISC" +} diff --git a/examples/magic/pages/api/login.js b/examples/magic/pages/api/login.js new file mode 100644 index 0000000000000..83217cd0fb06c --- /dev/null +++ b/examples/magic/pages/api/login.js @@ -0,0 +1,40 @@ +import express from 'express' +import passport from 'passport' +import { strategy } from '../../lib/magic' +import { encryptSession } from '../../lib/iron' +import { setTokenCookie } from '../../lib/auth-cookies' + +const app = express() +const authenticate = (method, req, res) => + new Promise((resolve, reject) => { + passport.authenticate(method, { session: false }, (error, token) => { + if (error) { + reject(error) + } else { + resolve(token) + } + })(req, res) + }) + +app.disable('x-powered-by') + +app.use(passport.initialize()) + +passport.use(strategy) + +app.post('/api/login', async (req, res) => { + try { + const user = await authenticate('magic', req, res) + // session is the payload to save in the token, it may contain basic info about the user + const session = { ...user } + // 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) { + console.error(error) + res.status(401).send(error.message) + } +}) + +export default app diff --git a/examples/magic/pages/api/logout.js b/examples/magic/pages/api/logout.js new file mode 100644 index 0000000000000..1e514b2bc3428 --- /dev/null +++ b/examples/magic/pages/api/logout.js @@ -0,0 +1,13 @@ +import { removeTokenCookie } from '../../lib/auth-cookies' +import { getSession } from '../../lib/iron' + +const { Magic } = require("@magic-sdk/admin") +const magic = new Magic(process.env.MAGIC_SECRET_KEY) + +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/magic/pages/api/user.js b/examples/magic/pages/api/user.js new file mode 100644 index 0000000000000..dad216c76e9a3 --- /dev/null +++ b/examples/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/magic/pages/index.js b/examples/magic/pages/index.js new file mode 100644 index 0000000000000..5797758b1f47f --- /dev/null +++ b/examples/magic/pages/index.js @@ -0,0 +1,36 @@ +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' + +const Home = () => { + const user = useUser() + + return ( + +

Magic Example

+ +

Steps to test the 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. +
+ + {user &&

Currently logged in as:

{JSON.stringify(user, null, 2)}

} + + +
+ ) +} + +export default Home diff --git a/examples/magic/pages/login.js b/examples/magic/pages/login.js new file mode 100644 index 0000000000000..2ddfe6ecd3bd0 --- /dev/null +++ b/examples/magic/pages/login.js @@ -0,0 +1,63 @@ +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/magic/pages/profile.js b/examples/magic/pages/profile.js new file mode 100644 index 0000000000000..b4822bcad500d --- /dev/null +++ b/examples/magic/pages/profile.js @@ -0,0 +1,15 @@ +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 From c73d7dc05d9b30be2dc38223369e97ab729f394c Mon Sep 17 00:00:00 2001 From: Sean Li Date: Fri, 10 Apr 2020 08:29:10 -0700 Subject: [PATCH 02/10] Tweaked wording on README --- examples/magic/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/magic/README.md b/examples/magic/README.md index 881a088d1501d..a01c533b1fddf 100644 --- a/examples/magic/README.md +++ b/examples/magic/README.md @@ -1,6 +1,6 @@ # 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. +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). From 81808008add11b07c32aa2a717bce89a16a362ff Mon Sep 17 00:00:00 2001 From: Sean Li Date: Fri, 10 Apr 2020 08:37:46 -0700 Subject: [PATCH 03/10] Fixed lint error --- examples/magic/components/form.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/magic/components/form.js b/examples/magic/components/form.js index 739530757061f..112cae74af4a2 100644 --- a/examples/magic/components/form.js +++ b/examples/magic/components/form.js @@ -1,5 +1,3 @@ -import Link from 'next/link' - const Form = ({ errorMessage, onSubmit }) => (