├── .gitignore ├── README.md ├── circle.yml ├── components ├── Menu.js ├── Page.js ├── SecurePage.js ├── SignInOrSignUp.js ├── ensureSignedIn.js ├── injectEnvironmentVar.js ├── injectSession.js └── wrapWithLayout.js ├── index.js ├── modules ├── compose.js └── extractSessionFromCookie.js ├── package.json └── pages ├── index.js ├── other.js ├── secret.js ├── sign-in.js ├── sign-out.js └── sign-up.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js example with auth 2 | 3 | [![CircleCI](https://circleci.com/gh/possibilities/next-with-auth.svg?style=svg)](https://circleci.com/gh/possibilities/next-with-auth) 4 | 5 | ### Usage 6 | 7 | First run the auth service in a terminal tab/window/panel 8 | 9 | ``` 10 | cd /tmp 11 | git clone https://github.com/possibilities/micro-auth.git 12 | cd micro-auth 13 | npm install 14 | AUTHENTICATION_SECRET_KEY=password123 API_PORT=5555 npm run dev 15 | ``` 16 | 17 | Then run the example app using the same secret and provide localhost as the api url 18 | 19 | ``` 20 | cd /tmp 21 | git clone https://github.com/possibilities/next-with-auth.git 22 | cd next-with-auth 23 | npm install 24 | AUTHENTICATION_SECRET_KEY=password123 AUTHENTICATION_API_URL=http://localhost:5555 npm run dev 25 | ``` 26 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9 4 | -------------------------------------------------------------------------------- /components/Menu.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/prefetch' 2 | 3 | export default ({ username }) => { 4 | return ( 5 |
6 | 28 | 29 | 34 | 35 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/Page.js: -------------------------------------------------------------------------------- 1 | import injectEnvironmentVar from './injectEnvironmentVar' 2 | import injectSession from './injectSession' 3 | import wrapWithLayout from './wrapWithLayout' 4 | import compose from '../modules/compose' 5 | 6 | const injectAuthApiUrl = injectEnvironmentVar('AUTHENTICATION_API_URL') 7 | 8 | // A pile of middleware that we use on every page 9 | export default compose(injectAuthApiUrl, injectSession, wrapWithLayout) 10 | -------------------------------------------------------------------------------- /components/SecurePage.js: -------------------------------------------------------------------------------- 1 | import Page from './Page' 2 | import ensureSignedIn from './ensureSignedIn' 3 | import compose from '../modules/compose' 4 | 5 | // Use the typical `Page` middleware and redirect to `/sign-in` when there's 6 | // no session. 7 | export default compose(Page, ensureSignedIn) 8 | -------------------------------------------------------------------------------- /components/SignInOrSignUp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import request from 'axios' 3 | import Cookie from 'js-cookie' 4 | import debounce from 'lodash.debounce' 5 | 6 | const BUTTON_LABEL = { 7 | signin: 'sign in', 8 | signup: 'sign up' 9 | } 10 | 11 | const ENDPOINT_PATH = { 12 | signin: 'sign-in', 13 | signup: 'sign-up' 14 | } 15 | 16 | const isProduction = process.env.NODE_ENV === 'production' 17 | 18 | if (!process.browser && !process.env.AUTHENTICATION_API_URL) { 19 | console.error('AUTHENTICATION_API_URL environment variable is required') 20 | process.exit(1) 21 | } 22 | 23 | export default class SignInOrSignUp extends React.Component { 24 | state = { errorMessage: null } 25 | 26 | render () { 27 | const { mode } = this.props 28 | const { errorMessage } = this.state 29 | 30 | return ( 31 |
32 | 46 | 47 | {errorMessage && ( 48 |
{errorMessage}
49 | )} 50 | 51 |
52 | 56 |
57 |
58 | 63 |
64 |
65 | 66 |
67 |
68 | ) 69 | } 70 | 71 | checkUsernameAvailable = async username => { 72 | const apiUrl = `${this.props.AUTHENTICATION_API_URL}/check-username/${username}` 73 | const response = await request.get(apiUrl) 74 | 75 | if (response.data.username === this.refs.username.value.trim()) { 76 | this.setState({ errorMessage: `username '${username}' is already taken` }) 77 | } 78 | } 79 | 80 | checkUsernameAvailableDebounced = 81 | debounce(this.checkUsernameAvailable, 500) 82 | 83 | handleUsernameKeyUp = event => { 84 | this.setState({ errorMessage: null }) 85 | if (event.keyCode === 13) { // Enter 86 | return this.handleSubmit() 87 | } 88 | if (this.props.mode === 'signup') { 89 | this.checkUsernameAvailableDebounced(this.refs.username.value.trim()) 90 | } 91 | } 92 | 93 | handlePasswordKeyUp = event => { 94 | this.setState({ errorMessage: null }) 95 | if (event.keyCode === 13) { // Enter 96 | return this.handleSubmit() 97 | } 98 | } 99 | 100 | handleSubmit = async () => { 101 | this.setState({ errorMessage: null }) 102 | 103 | const { mode } = this.props 104 | 105 | const username = this.refs.username.value.trim() 106 | const password = this.refs.password.value.trim() 107 | 108 | let response 109 | try { 110 | const apiUrl = `${this.props.AUTHENTICATION_API_URL}/${ENDPOINT_PATH[mode]}` 111 | response = await request.post(apiUrl, { username, password }) 112 | } catch (error) { 113 | this.setState({ errorMessage: error.response.data.message }) 114 | console.error(error) 115 | } 116 | 117 | if (response) { 118 | const session = response.data 119 | 120 | // Store the token for the benefit of client and server 121 | window.localStorage.setItem('session', JSON.stringify(session)) 122 | Cookie.set('token', session.token, { secure: isProduction }) 123 | 124 | // Redirect to the next URL or home 125 | const nextUrl = this.props.url.query.next 126 | this.props.url.push(nextUrl || '/') 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /components/ensureSignedIn.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /* 4 | * Causes page component to be redirected to `/sign-in` if there is no 5 | * `this.props.session` available. Tacks on `?next=` query param with the 6 | * `window.location` which the sign in page can redirect user back to on 7 | * successful authentication. 8 | */ 9 | 10 | const ensureSignedIn = Page => { 11 | return class EnsureSignedIn extends React.Component { 12 | static getInitialProps (context) { 13 | // If the page has a prop fetcher invoke it 14 | return Page.getInitialProps ? Page.getInitialProps(context) : {} 15 | } 16 | 17 | constructor (props) { 18 | super(props) 19 | 20 | // On the client redirect right away to the sign in page if there's no 21 | // session 22 | if (process.browser && !props.session) { 23 | this.props.url.push('/sign-in?next=' + encodeURI(window.location)) 24 | } 25 | } 26 | 27 | componentWillReceiveProps (nextProps) { 28 | // On the client redirect to the sign in page if the session gets signed 29 | // out in another tab 30 | if (process.browser && !nextProps.session) { 31 | this.props.url.push('/sign-in?next=' + encodeURI(window.location)) 32 | } 33 | } 34 | 35 | render () { 36 | if (this.props.session) { 37 | return ( 38 |
39 | 40 |
41 | ) 42 | } else { 43 | return null 44 | } 45 | } 46 | } 47 | } 48 | 49 | export default ensureSignedIn 50 | -------------------------------------------------------------------------------- /components/injectEnvironmentVar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /* 4 | * Enabled making environment variables available to pages loaded on the 5 | * server or client. This won't work at all unless every page in the app uses 6 | * this HoC because it relies on the initial server-rendered page to pass it 7 | * along to client-rendered pages. E.g. 8 | * 9 | * ``` 10 | * const injectApiUrl = injectEnvironmentVar('API_URL') 11 | * const Home = () =>
Welcome home!
12 | * export default injectApiUrl(Home) 13 | * ``` 14 | */ 15 | 16 | const injectEnvironmentVar = varName => Page => { 17 | // Blow the whole thing up if the env var isn't present 18 | if (!process.browser && typeof (process.env[varName]) === undefined) { 19 | console.error(`${varName} environment variable is required`) 20 | process.exit(1) 21 | } 22 | 23 | return class InjectEnvironmentVar extends React.Component { 24 | static getInitialProps (context) { 25 | // Get the page's own initial props 26 | const initialProps = Page.getInitialProps ? Page.getInitialProps(context) : {} 27 | // Dig the specified environment variables up from the 28 | // appropriate sources 29 | const env = process.browser ? global.__next_env : process.env 30 | // Mix it in with the environment values 31 | return { ...initialProps, [varName]: env[varName] } 32 | } 33 | 34 | constructor (props) { 35 | super(props) 36 | // If we're on the client we want to copy all environment variables 37 | // to the global namespace so other pages will be able to grab them 38 | // when fetching their initial props. 39 | if (process.browser) { 40 | global.__next_env = global.__next_env || {} 41 | global.__next_env[varName] = props[varName] 42 | } 43 | } 44 | 45 | render () { 46 | return ( 47 | 48 | ) 49 | } 50 | } 51 | } 52 | 53 | export default injectEnvironmentVar 54 | -------------------------------------------------------------------------------- /components/injectSession.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /* 4 | * Dredges up a `session` object from cookie or localStorage and, if present, 5 | * injects it as a prop. Also keeps track of the current session in 6 | * component state so that multiple tabs open in the same browser can react 7 | * to sign ins/outs. 8 | */ 9 | 10 | // Pull the session out of local storage and parse it muffling any errors 11 | const getSessionFromLocalStorage = () => { 12 | try { 13 | return JSON.parse(window.localStorage.getItem('session')) 14 | } catch (error) { 15 | console.error(error) 16 | } 17 | } 18 | 19 | const injectSession = Page => { 20 | return class InjectSession extends React.Component { 21 | constructor (props) { 22 | super(props) 23 | // As the session changes we need a spot to store in a way that updates 24 | // children components 25 | this.state = {} 26 | } 27 | 28 | static getInitialProps (context) { 29 | // Get the page's own initial props 30 | const initialProps = Page.getInitialProps ? Page.getInitialProps(context) : {} 31 | 32 | // Dig the session out of localstorage (on client) or the request (on 33 | // server) 34 | const session = process.browser 35 | ? getSessionFromLocalStorage() 36 | : context.req.session 37 | 38 | // Inject any initial props and session 39 | return { ...initialProps, session } 40 | } 41 | 42 | render () { 43 | // Pass on whatever props exist and state 44 | return 45 | } 46 | 47 | componentWillMount () { 48 | // Use component state to track the session 49 | if (process.browser) { 50 | window.addEventListener('storage', this.handleStorageChange) 51 | } 52 | } 53 | 54 | componentWillUnmount () { 55 | // Stop tracking session 56 | if (process.browser) { 57 | window.removeEventListener('storage', this.handleStorageChange) 58 | } 59 | } 60 | 61 | handleStorageChange = event => { 62 | // Keep component state up to date with current session as it changes. 63 | // This is so other tabs open in the same browser can respond to session 64 | // changes. 65 | if (event.key === 'session') { 66 | if (event.newValue) { 67 | this.setState({ session: JSON.parse(event.newValue) }) 68 | } else { 69 | this.setState({ session: null }) 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | export default injectSession 77 | -------------------------------------------------------------------------------- /components/wrapWithLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Menu from './Menu' 3 | 4 | // Wraps a global layout around a page 5 | 6 | const wrapWithLayout = Page => { 7 | return class WrapWithLayout extends React.Component { 8 | static getInitialProps (context) { 9 | return Page.getInitialProps 10 | ? Page.getInitialProps(context) 11 | : {} 12 | } 13 | 14 | render () { 15 | const { username } = this.props.session || {} 16 | 17 | return ( 18 |
19 | 27 | 28 |
29 | 30 | 31 |
32 |
33 | ) 34 | } 35 | } 36 | } 37 | 38 | export default wrapWithLayout 39 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const micro = require('micro') 2 | const next = require('next') 3 | const extractSessionFromCookie = require('./modules/extractSessionFromCookie') 4 | 5 | /* 6 | * A custom next.js server using micro. This is primarily so we can add some 7 | * middleware; we don't do anything special in terms of routing. 8 | */ 9 | 10 | const isProduction = process.env.NODE_ENV === 'production' 11 | const port = 3000 12 | 13 | // Let `next` do everything but use this opportunity to parse the cookie, 14 | // decode the JWT and cache it at `req.session` 15 | const main = async () => { 16 | const app = next({ dev: !isProduction }) 17 | const nextRequestHandler = app.getRequestHandler() 18 | 19 | await app.prepare() 20 | 21 | micro((req, res) => { 22 | // Extract the session and cache it 23 | req.session = extractSessionFromCookie(req) 24 | 25 | // Let next handle the request 26 | return nextRequestHandler(req, res) 27 | }).listen(port) 28 | 29 | console.info(`listening on port ${port}...`) 30 | } 31 | 32 | main() 33 | -------------------------------------------------------------------------------- /modules/compose.js: -------------------------------------------------------------------------------- 1 | const compose = (...fns) => (...args) => { 2 | fns.reverse().forEach(fn => { 3 | if (!Array.isArray(args)) { 4 | args = [args] 5 | } 6 | args = fn.apply(null, args) 7 | }) 8 | return args 9 | } 10 | 11 | module.exports = compose 12 | -------------------------------------------------------------------------------- /modules/extractSessionFromCookie.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | 3 | const authenticationSecretKey = process.env.AUTHENTICATION_SECRET_KEY 4 | 5 | if (!process.browser && !authenticationSecretKey) { 6 | console.error('AUTHENTICATION_SECRET_KEY environment variable is required') 7 | process.exit(1) 8 | } 9 | 10 | const extractSessionFromCookie = req => { 11 | const { cookie } = req.headers 12 | 13 | // Skip if there's no cookie 14 | // Also skip urls that start with `_` e.g. `__next/*` 15 | if (cookie && req.url[1] !== '_') { 16 | // Pull out the `token` cookie 17 | const tokenCookie = cookie 18 | .split(';') 19 | .map(s => s.trim()) 20 | .find(s => s.startsWith('token=')) 21 | 22 | // If there's a token 23 | if (tokenCookie) { 24 | // Pull out and cleanup token 25 | const token = tokenCookie.split('=').pop().trim() 26 | 27 | // If there's a cleaned up token 28 | if (token) { 29 | // Decode the token and return it 30 | let session 31 | try { 32 | session = jwt.verify(token, authenticationSecretKey) 33 | return session 34 | } catch (error) { 35 | console.error(error) 36 | } 37 | } 38 | } 39 | } 40 | 41 | return null 42 | } 43 | 44 | module.exports = extractSessionFromCookie 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "next build", 4 | "start": "NODE_ENV=production async-node ./index.js", 5 | "dev": "NODE_ENV=development async-node ./index.js", 6 | "test": "echo noop", 7 | "lint": "standard", 8 | "pretest": "npm run lint" 9 | }, 10 | "dependencies": { 11 | "async-to-gen": "1.3.0", 12 | "axios": "0.15.3", 13 | "js-cookie": "2.1.3", 14 | "jsonwebtoken": "7.2.1", 15 | "lodash.debounce": "4.0.8", 16 | "micro": "6.2.0", 17 | "next": "2.0.0-beta.17" 18 | }, 19 | "standard": { 20 | "parser": "babel-eslint" 21 | }, 22 | "devDependencies": { 23 | "babel-eslint": "^7.1.1", 24 | "standard": "^8.6.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/prefetch' 3 | import Page from '../components/Page' 4 | 5 | class Home extends React.Component { 6 | render () { 7 | const { session } = this.props 8 | 9 | if (session) { 10 | const { username } = session 11 | return ( 12 |
welcome home, {username}!
13 | ) 14 | } else { 15 | return ( 16 |
17 | sign in to continue! 18 |
19 | ) 20 | } 21 | } 22 | } 23 | 24 | export default Page(Home) 25 | -------------------------------------------------------------------------------- /pages/other.js: -------------------------------------------------------------------------------- 1 | import Page from '../components/Page' 2 | 3 | const Other = () =>
another page, just for demo purposes
4 | 5 | export default Page(Other) 6 | -------------------------------------------------------------------------------- /pages/secret.js: -------------------------------------------------------------------------------- 1 | import SecurePage from '../components/SecurePage' 2 | 3 | const Secret = () =>
this secret can only be seen when you're signed in
4 | 5 | export default SecurePage(Secret) 6 | -------------------------------------------------------------------------------- /pages/sign-in.js: -------------------------------------------------------------------------------- 1 | import SignInOrSignUp from '../components/SignInOrSignUp' 2 | import Page from '../components/Page' 3 | 4 | const SignIn = props => 5 | 6 | export default Page(SignIn) 7 | -------------------------------------------------------------------------------- /pages/sign-out.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Page from '../components/Page' 3 | import Cookie from 'js-cookie' 4 | 5 | class SignOut extends React.Component { 6 | componentDidMount () { 7 | if (process.browser) { 8 | window.localStorage.removeItem('session') 9 | Cookie.remove('token') 10 | } 11 | this.props.url.push('/') 12 | } 13 | 14 | render () { 15 | return ( 16 |
signing out...
17 | ) 18 | } 19 | } 20 | 21 | export default Page(SignOut) 22 | -------------------------------------------------------------------------------- /pages/sign-up.js: -------------------------------------------------------------------------------- 1 | import SignInOrSignUp from '../components/SignInOrSignUp' 2 | import Page from '../components/Page' 3 | 4 | const SignUp = props => 5 | 6 | export default Page(SignUp) 7 | --------------------------------------------------------------------------------