|
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(); |