├── .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 | [](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 |
30 |
home
31 |
other
32 |
secret
33 |
34 |
35 |
36 | {!username &&
sign in
}
37 | {!username &&
sign up
}
38 | {username && (
39 |
40 | {username} sign out
41 |
42 | )}
43 |
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 |