Skip to content

Commit

Permalink
chore(examples): add Authsignal passwordless example (#41079)
Browse files Browse the repository at this point in the history
Lands #39048 with lint fixes. Needed to open a new PR, because GitHub
does not allow maintainers to edit organization forks.

https://github.com/orgs/community/discussions/5634

Closes #39048

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)

Co-authored-by: Chris Fisher <chris.i.fisher@gmail.com>
  • Loading branch information
balazsorban44 and chrisfisher committed Oct 1, 2022
1 parent c988b99 commit e875dde
Show file tree
Hide file tree
Showing 23 changed files with 476 additions and 0 deletions.
3 changes: 3 additions & 0 deletions examples/authsignal/passwordless-login/.env.local.example
@@ -0,0 +1,3 @@
AUTHSIGNAL_SECRET=
SESSION_TOKEN_SECRET=
REDIRECT_URL=http://localhost:3000/api/finalize-login
36 changes: 36 additions & 0 deletions examples/authsignal/passwordless-login/.gitignore
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
45 changes: 45 additions & 0 deletions examples/authsignal/passwordless-login/README.md
@@ -0,0 +1,45 @@
# Authsignal Passwordless Login Example

This example shows how to integrate Authsignal with Next.js in order to implement passwordless login using email magic links and server-side redirects.

The login session is managed using cookies. Session data is encrypted using [@hapi/iron](https://hapi.dev/family/iron).

A live version of this example can be found [here](https://authsignal-next-passwordless-example.vercel.app).

## Deploy your own

Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example):

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/authsignal-passwordless&project-name=authsignal-passwordless&repository-name=authsignal-passwordless)

## How to use

Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:

```bash
npx create-next-app --example authsignal-passwordless authsignal-passwordless-app
# or
yarn create next-app --example authsignal-passwordless authsignal-passwordless-app
# or
pnpm create next-app --example authsignal-passwordless authsignal-passwordless-app
```

Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).

## Configuration

Log in to the [Authsignal Portal](https://portal.authsignal.com) and [enable email magic links for your tenant](https://portal.authsignal.com/organisations/tenants/authenticators).

Copy the .env.local.example file to .env.local:

```
cp .env.local.example .env.local
```

Set `AUTHSIGNAL_SECRET` as your [Authsignal secret key](https://portal.authsignal.com/organisations/tenants/api).

The `SESSION_TOKEN_SECRET` is used to encrypt the session cookie. Set it to a random string of 32 characters.

## Notes

To learn more about Authsignal take a look at the [API Documentation](https://docs.authsignal.com/).
@@ -0,0 +1,38 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #1d1d1d;
width: 100%;
}

.header button {
cursor: pointer;
font-size: 13px;
font-weight: 500;
line-height: 1;
border: none;
background: none;
color: #fff;
padding: 15px;
transition: background-color 0.15s, color 0.15s;
}

.user {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
flex-grow: 1;
}

.label {
font-size: 12px;
margin-bottom: 5px;
}

.logo {
font-size: 18px;
margin: 15px;
color: #fff;
}
35 changes: 35 additions & 0 deletions examples/authsignal/passwordless-login/components/dashboard.tsx
@@ -0,0 +1,35 @@
import { useRouter } from 'next/router'
import { User } from '../lib'
import styles from './dashboard.module.css'

interface Props {
user: User
}

export const Dashboard = ({ user }: Props) => {
const router = useRouter()

const logout = async () => {
await fetch('/api/logout', {
method: 'POST',
credentials: 'same-origin',
})

router.push('/')
}

return (
<>
<header className={styles.header}>
<div className={styles.logo}>My Example App</div>
<button onClick={() => logout()}>Log out</button>
</header>
<div className={styles.user}>
<div>
<div className={styles.label}>Logged in as:</div>
<div>{user.email}</div>
</div>
</div>
</>
)
}
3 changes: 3 additions & 0 deletions examples/authsignal/passwordless-login/components/index.ts
@@ -0,0 +1,3 @@
export * from './dashboard'
export * from './layout'
export * from './login'
16 changes: 16 additions & 0 deletions examples/authsignal/passwordless-login/components/layout.tsx
@@ -0,0 +1,16 @@
import Head from 'next/head'

type Props = {
children: React.ReactNode
}

export const Layout = (props: Props) => (
<>
<Head>
<title>Authsignal Passwordless Example</title>
<link rel="icon" href="/favicon.ico" />
</Head>

{props.children}
</>
)
57 changes: 57 additions & 0 deletions examples/authsignal/passwordless-login/components/login.module.css
@@ -0,0 +1,57 @@
.login {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
}

.login form {
display: flex;
flex-direction: column;
min-width: 300px;
}

.login label {
font-size: 12px;
margin-bottom: 5px;
color: #ababab;
}

.login input {
outline: none;
font-family: inherit;
font-size: 13px;
font-weight: 400;
background-color: #fff;
border-radius: 6px;
color: #1d1d1d;
border: 1px solid #e8e8e8;
padding: 0 15px;
margin: 0 0 15px 0;
height: 40px;
}

.login button {
cursor: pointer;
font-size: 13px;
font-weight: 500;
line-height: 1;
border-radius: 6px;
border: none;
background-color: #1d1d1d;
color: #fff;
padding: 0 15px;
height: 40px;
transition: background-color 0.15s, color 0.15s;
}

.login button:hover:not(:active) {
background-color: #282828;
}

.title {
font-size: 24px;
margin-bottom: 30px;
font-weight: 400;
}
12 changes: 12 additions & 0 deletions examples/authsignal/passwordless-login/components/login.tsx
@@ -0,0 +1,12 @@
import styles from './login.module.css'

export const Login = () => (
<main className={styles.login}>
<h1 className={styles.title}>My Example App</h1>
<form method="POST" action="/api/login">
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" required />
<button type="submit">Log in / Sign up</button>
</form>
</main>
)
5 changes: 5 additions & 0 deletions examples/authsignal/passwordless-login/lib/authsignal.ts
@@ -0,0 +1,5 @@
import { Authsignal } from '@authsignal/node'

const secret = process.env.AUTHSIGNAL_SECRET!

export const authsignal = new Authsignal({ secret })
57 changes: 57 additions & 0 deletions examples/authsignal/passwordless-login/lib/cookies.ts
@@ -0,0 +1,57 @@
import Iron from '@hapi/iron'
import { parse, serialize } from 'cookie'

export const COOKIE_NAME = 'session_token'

const TOKEN_SECRET = process.env.SESSION_TOKEN_SECRET!

export async function createCookieForSession(user: User) {
// Make login session valid for 8 hours
const maxAge = 60 * 60 * 8

const expires = new Date()
expires.setSeconds(expires.getSeconds() + maxAge)

const sessionData: SessionData = { user, expiresAt: expires.toString() }

const sessionToken = await Iron.seal(sessionData, TOKEN_SECRET, Iron.defaults)

const cookie = serialize(COOKIE_NAME, sessionToken, {
maxAge,
expires,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
sameSite: 'lax',
})

return cookie
}

export async function getSessionFromCookie(cookie: string | undefined) {
const cookies = parse(cookie ?? '')

const sessionToken = cookies[COOKIE_NAME]

if (!sessionToken) {
return undefined
}

const sessionData: SessionData = await Iron.unseal(
sessionToken,
TOKEN_SECRET,
Iron.defaults
)

return sessionData
}

export interface SessionData {
user: User
expiresAt: string
}

export interface User {
userId: string
email?: string
}
2 changes: 2 additions & 0 deletions examples/authsignal/passwordless-login/lib/index.ts
@@ -0,0 +1,2 @@
export * from './authsignal'
export * from './cookies'
6 changes: 6 additions & 0 deletions examples/authsignal/passwordless-login/next.config.js
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}

module.exports = nextConfig
23 changes: 23 additions & 0 deletions examples/authsignal/passwordless-login/package.json
@@ -0,0 +1,23 @@
{
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@authsignal/node": "^0.0.29",
"@hapi/iron": "^7.0.0",
"cookie": "^0.5.0",
"next": "latest",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/node": "18.0.3",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"typescript": "4.7.4"
}
}
6 changes: 6 additions & 0 deletions examples/authsignal/passwordless-login/pages/_app.tsx
@@ -0,0 +1,6 @@
import type { AppProps } from 'next/app'
import './globals.css'

export default function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
27 changes: 27 additions & 0 deletions examples/authsignal/passwordless-login/pages/api/finalize-login.ts
@@ -0,0 +1,27 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { authsignal, createCookieForSession } from '../../lib'

// This route handles the redirect back from the Authsignal Prebuilt MFA page
export default async function finalizeLogin(
req: NextApiRequest,
res: NextApiResponse
) {
// Only GET requests since we are handling redirects
if (req.method !== 'GET') {
return res.status(405).send({ message: 'Only GET requests allowed' })
}

const token = req.query.token as string

// This step uses your secret key to validate the token returned via the redirect
// It makes an authenticated call to Authsignal to check if the magic link challenge succeeded
const { success, user } = await authsignal.validateChallenge({ token })

if (success) {
const cookie = await createCookieForSession(user)

res.setHeader('Set-Cookie', cookie)
}

res.redirect('/')
}

0 comments on commit e875dde

Please sign in to comment.