Skip to content

Instantly share code, notes, and snippets.

@sholladay
Last active September 25, 2022 00:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sholladay/b34d17818a2fc60adb2b040766081772 to your computer and use it in GitHub Desktop.
Save sholladay/b34d17818a2fc60adb2b040766081772 to your computer and use it in GitHub Desktop.
WebAuthn demo

WebAuthn Demo

This demo app implements a signup & login system based on Web Authentication.

iOS 16 or macOS Ventura is recommended for the device that will log in to the app, however Android, Windows, and earlier versions of iOS/macOS will also work, with some limitations.

Run

  1. Download and unzip this gist
  2. cd into the directory
  3. Start the server:
    deno run --allow-env --allow-net --allow-read='.' server.tsx
  4. Get a public URL for the server to easily access it on your phone:
    npx localtunnel --port=3000
  5. Visit the tunnel URL on your phone and have fun!

Routes

Path Description
/ The home page has the signup form and login button
/profile A protected page that you must log in to view, otherwise it returns an error
/client.js Client-side JavaScript for the home page form and buttons
/signup/start Returns attestation options encoded as JSON and sets a challenge cookie
/signup/finish Validates the client's attestation and signature against the challenge cookie
/login/start Returns assertion options encoded as JSON and sets a challenge cookie
/login/finish Validates the client's assertion and signature against the challenge cookie
import ky from 'https://esm.sh/ky';
import { create, get } from 'https://esm.sh/@github/webauthn-json';
const signupForm = document.querySelector('#signup');
const loginButton = document.querySelector('#login');
loginButton.addEventListener('click', () => {
logIn();
});
signupForm.addEventListener('submit', (evt) => {
evt.preventDefault();
const username = signupForm.querySelector('#username').value;
const displayName = signupForm.querySelector('#display-name').value || username;
signUp({
username,
displayName
});
});
const signUp = async (user) => {
const options = await ky.post('/signup/start', {
json : user
}).json();
const credential = await create({ publicKey : options });
await ky.post('/signup/finish', {
json : credential
});
location.href = '/profile';
};
const logIn = async () => {
const options = await ky.post('/login/start').json();
const credential = await get({ publicKey : options });
await ky.post('/login/finish', {
json : credential,
});
location.href = '/profile';
};
import { coerceToArrayBuffer, coerceToBase64Url, Fido2Lib } from "https://deno.land/x/fido2/dist/main.js";
import React from 'https://esm.sh/react';
import pogo from 'https://deno.land/x/pogo/main.ts';
import * as bang from 'https://deno.land/x/pogo/lib/bang.ts';
const database = {
users : new Map(),
credentials : new Map()
};
const createUser = (user) => {
if (database.users.has(user.id)) {
throw bang.conflict('Unable to create user because a user with that ID already exists');
}
for (const existingUser of database.users) {
if (existingUser.username === user.username) {
throw bang.conflict('Unable to create user because a user with that username already exists');
}
}
database.users.set(user.id, {
username : user.username,
displayName : user.displayName
});
}
const auth = new Fido2Lib({
timeout: 1000 * 60,
// rpId: 'localhost',
rpName: 'ACME',
rpIcon: 'https://dev.chords.io/static/img/icon.svg',
challengeSize: 128,
attestation: 'direct',
cryptoParams: [-8, -7],
authenticatorRequireResidentKey: true,
authenticatorUserVerification: 'required'
});
const server = pogo.server({ port : 3000 });
const encodeOptions = (options) => {
options.challenge = coerceToBase64Url(options.challenge, 'challenge');
if (options.user?.id) {
options.user.id = coerceToBase64Url(options.user.id, 'user.id');
}
if (options.excludeCredentials) {
for (const credential of options.excludeCredentials) {
credential.id = coerceToBase64Url(credential.id, 'credential.id');
}
}
return options;
};
const decodeCredential = (credential) => {
credential.rawId = coerceToArrayBuffer(credential.rawId, 'rawId');
return credential;
};
server.router.get('/', () => {
return (
<html>
<head>
<title>Awesome App</title>
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/system-ui.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/ui-monospace.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/typography.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/assets.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/forms.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@12/page.css" />
<script type="module" src="/client.js" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2" />
</head>
<body>
<h1>Awesome App</h1>
<p>A demonstration of passwordless login with <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API">Web Authentication</a>.</p>
<p>After logging in, you will be redirected to <a href="/profile">your profile</a>.</p>
<h2>Use an Existing Account</h2>
<button id="login">Log in</button>
<h2>Create a New Account</h2>
<form id="signup">
<label htmlFor="username">Username</label>
<br />
<input id="username" name="username" placeholder="janedoe" type="text" />
<br />
<label htmlFor="display-name">Display Name</label>
<br />
<input id="display-name" name="displayName" placeholder="Jane Doe" type="text" />
<br /><br />
<button type="submit">Sign up</button>
</form>
</body>
</html>
);
});
server.router.post('/signup/start', async (request, h) => {
const data = await request.raw.json();
const userId = crypto.randomUUID();
createUser({
id : userId,
username : data.username,
displayName : data.displayName
});
let attestationOptions = await auth.attestationOptions();
attestationOptions.user.id = userId;
attestationOptions.user.name = data.username;
attestationOptions.user.displayName = data.displayName;
attestationOptions = encodeOptions(attestationOptions);
return h.response(attestationOptions)
.state('challenge', attestationOptions.challenge)
.state('userId', userId);
});
server.router.post('/signup/finish', async (request, h) => {
const credential = decodeCredential(await request.raw.json());
const expectations = {
challenge : request.state.challenge,
origin : request.headers.get('origin'),
factor : 'first'
};
const result = await auth.attestationResult(credential, expectations);
database.credentials.set(credential.id, {
publicKey : result.authnrData.get('credentialPublicKeyPem'),
signCount : result.authnrData.get('counter'),
userId : request.state.userId
});
return h.response('success')
.unstate('challenge')
.unstate('userId')
.state('__Host-session', {
path : '/',
sameSite : 'Lax',
value : credential.id
});
});
server.router.post('/login/start', async (request, h) => {
let assertionOptions = await auth.assertionOptions();
assertionOptions = encodeOptions(assertionOptions);
return h.response(assertionOptions)
.state('challenge', assertionOptions.challenge);
});
server.router.post('/login/finish', async (request, h) => {
const credential = decodeCredential(await request.raw.json());
const expectations = {
challenge : request.state.challenge,
origin : request.headers.get('origin'),
factor : 'first',
prevCounter : database.credentials.get(credential.id).signCount,
publicKey : database.credentials.get(credential.id).publicKey,
userHandle : database.credentials.get(credential.id).userId
};
const result = await auth.assertionResult(credential, expectations);
return h.response('success')
.unstate('challenge')
.state('__Host-session', {
path : '/',
sameSite : 'Lax',
value : credential.id
});
});
server.router.get('/logout', (request, h) => {
return h.redirect('/').unstate('__Host-session');
});
server.router.get('/profile', (request) => {
const session = request.state['__Host-session'];
if (session) {
const { userId } = database.credentials.get(session);
const { username } = database.users.get(userId);
return (
<html>
<head>
<title>Your Profile</title>
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/system-ui.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/ui-monospace.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/typography.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/assets.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@13/forms.css" />
<link rel="stylesheet" href="https://unpkg.com/sanitize.css@12/page.css" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2" />
</head>
<body>
<h1>Your Profile</h1>
<p>You are logged in as <b>@{username}</b>!</p>
<p>You can go <a href="/">home</a> or <a href="/logout">log out</a>.</p>
</body>
</html>
);
}
return bang.unauthorized('Please log in to view this page');
});
server.router.get('/client.js', (request, h) => {
return h.file('./client.js');;
});
server.start();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment