Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authsignal passwordless example #41079

Merged
merged 7 commits into from Oct 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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}
</>
)
@@ -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} />
}
@@ -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('/')
}