|
import { |
|
generateAuthenticationOptions, |
|
generateRegistrationOptions, |
|
verifyAuthenticationResponse, |
|
verifyRegistrationResponse |
|
} from 'jsr:@simplewebauthn/server@^13.1.2'; |
|
import React from 'https://esm.sh/react@18'; |
|
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 server = pogo.server({ port : 3000 }); |
|
|
|
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 |
|
}); |
|
|
|
const signupOptions = await generateRegistrationOptions({ |
|
rpName : 'ACME', |
|
rpID : '', |
|
userName : data.username, |
|
userID : new TextEncoder().encode(userId), |
|
userDisplayName : data.displayName, |
|
authenticatorSelection : { |
|
userVerification : 'required', |
|
residentKey : 'required' |
|
} |
|
}); |
|
// Use the current domain |
|
delete signupOptions.rp.id; |
|
|
|
return h.response(signupOptions) |
|
.state('challenge', signupOptions.challenge) |
|
.state('userId', userId); |
|
}); |
|
server.router.post('/signup/finish', async (request, h) => { |
|
const credential = await request.raw.json(); |
|
const { verified, registrationInfo } = await verifyRegistrationResponse({ |
|
expectedChallenge : request.state.challenge, |
|
expectedOrigin : request.headers.get('Origin') || `https://${request.host}`, |
|
expectedRPID : request.hostname, |
|
requireUserPresence : true, |
|
requireUserVerification : true, |
|
response : credential |
|
}); |
|
|
|
const response = h.response( |
|
verified && registrationInfo ? |
|
'success' : |
|
bang.badRequest('Passkey failed verification') |
|
) |
|
.unstate('challenge') |
|
.unstate('userId'); |
|
|
|
if (verified && registrationInfo) { |
|
database.credentials.set(credential.id, { |
|
publicKey : registrationInfo.credential.publicKey, |
|
userId : request.state.userId |
|
}); |
|
|
|
response.state('__Host-session', { |
|
path : '/', |
|
sameSite : 'Lax', |
|
value : credential.id |
|
}) |
|
} |
|
|
|
return response; |
|
}); |
|
server.router.post('/login/start', async (request, h) => { |
|
const loginOptions = await generateAuthenticationOptions({ |
|
rpID : '', |
|
userVerification : 'required' |
|
}); |
|
// Use current domain |
|
delete loginOptions.rpId; |
|
|
|
return h.response(loginOptions) |
|
.state('challenge', loginOptions.challenge); |
|
}); |
|
server.router.post('/login/finish', async (request, h) => { |
|
const credential = await request.raw.json(); |
|
const { verified, authenticationInfo } = await verifyAuthenticationResponse({ |
|
credential : database.credentials.get(credential.id), |
|
expectedChallenge : request.state.challenge, |
|
expectedOrigin : request.headers.get('Origin') || `https://${request.host}`, |
|
expectedRPID : request.hostname, |
|
requireUserVerification : true, |
|
response : credential |
|
}); |
|
|
|
const response = h.response( |
|
verified && authenticationInfo ? |
|
'success' : |
|
bang.badRequest('Passkey failed verification') |
|
) |
|
.unstate('challenge'); |
|
|
|
if (verified && authenticationInfo) { |
|
response.state('__Host-session', { |
|
path : '/', |
|
sameSite : 'Lax', |
|
value : credential.id |
|
}) |
|
} |
|
|
|
return response; |
|
}); |
|
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(); |