Skip to content

Commit

Permalink
Merge pull request #39 from GoogleChromeLabs/dev
Browse files Browse the repository at this point in the history
Enable Signal API, better config management, etc
  • Loading branch information
agektmr authored Jan 18, 2025
2 parents cd60f6d + e35bb48 commit 9fa4e54
Show file tree
Hide file tree
Showing 16 changed files with 5,479 additions and 6,314 deletions.
1 change: 1 addition & 0 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
runtime: nodejs20
env_variables:
NODE_ENV: production
SESSION_SECRET: 'set your own secret'
handlers:
- url: /.*
secure: always
Expand Down
71 changes: 71 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import firebaseJson from './firebase.json' with { type: 'json' };
import { getFirestore } from 'firebase-admin/firestore';
import { initializeApp } from 'firebase-admin/app';

const is_localhost = process.env.NODE_ENV === 'localhost';
const env = is_localhost ? 'development' : 'production';
const _config = (await import(`./${env}.config.json`, {with:{type: 'json'}})).default;

function generateApkKeyHash(fingerprint) {
const hexString = fingerprint.replace(/:/g, '');

// Convert hex string to byte array
const bytes = new Uint8Array(hexString.length / 2);
for (let i = 0; i < hexString.length; i += 2) {
bytes[i / 2] = parseInt(hexString.substr(i, 2), 16);
}

// Encode byte array to base64url
const base64url = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');

return `android:apk-key-hash:${base64url}`;
}

const { hostname, port, associated_domains = [], secret, rp_name, project_name } = _config;

const domain = port ? `${hostname}:${port}` : hostname;
const origin = is_localhost ? `http://${domain}` : `https://${domain}`;

const associated_origins = [];
for (let domain of associated_domains) {
const associated_origin = generateApkKeyHash(domain.sha256_cert_fingerprints);
associated_origins.push(associated_origin);
}

const config = {
env,
hostname,
domain,
origin,
secret: 'set your own secret in the config file',
rp_name: rp_name || 'Passkeys Demo',
project_name: project_name || 'passkeys-demo',
associated_domains: [
origin,
...associated_domains
],
associated_origins: [
origin,
...associated_origins
]
};

function initializeFirestore() {
if (is_localhost) {
process.env.GOOGLE_CLOUD_PROJECT = config.project_name;
process.env.FIRESTORE_EMULATOR_HOST = `${firebaseJson.emulators.firestore.host}:${firebaseJson.emulators.firestore.port}`;
}

initializeApp();

const store = getFirestore(process.env.FIRESTORE_DATABASENAME || '');
store.settings({ignoreUndefinedProperties: true});
return store;
}

const store = initializeFirestore();

export { config, store };
6 changes: 6 additions & 0 deletions development.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"hostname": "localhost",
"port": 8080,
"rp_name": "Passkeys Demo",
"project_name": "passkeys-demo"
}
89 changes: 30 additions & 59 deletions libs/auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Users, Credentials } from './db.mjs';
import aaguids from 'aaguid' with { type: 'json' };
import { config } from '../config.js';

router.use(express.json());

Expand All @@ -52,43 +53,6 @@ async function sessionCheck(req, res, next) {
next();
};

/**
* Get the expected origin that the user agent is claiming to be at. If the
* requester is Android, construct an expected `origin` parameter.
* @param { string } userAgent A user agent string used to check if it's a web browser.
* @returns A string that indicates an expected origin.
*/
function getOrigin(userAgent) {
let origin = process.env.ORIGIN;

const appRe = /^[a-zA-z0-9_.]+/;
const match = userAgent.match(appRe);
if (match) {
// Check if UserAgent comes from a supported Android app.
if (process.env.ANDROID_PACKAGENAME && process.env.ANDROID_SHA256HASH) {
// `process.env.ANDROID_PACKAGENAME` is expected to have a comma separated package names.
const package_names = process.env.ANDROID_PACKAGENAME.split(",").map(name => name.trim());
// `process.env.ANDROID_SHA256HASH` is expected to have a comma separated hash values.
const hashes = process.env.ANDROID_SHA256HASH.split(",").map(hash => hash.trim());
const appName = match[0];
// Find and construct the expected origin string.
for (let i = 0; i < package_names.length; i++) {
if (appName === package_names[i]) {
// We recognize this app, so use the corresponding hash.
const octArray = hashes[i].split(':').map((h) =>
parseInt(h, 16),
);
const androidHash = isoBase64URL.fromBuffer(octArray);
origin = `android:apk-key-hash:${androidHash}`;
break;
}
}
}
}

return origin;
}

router.get('/aaguids', (req, res) => {
if (Object.keys(aaguids).length === 0) {
return res.json();
Expand Down Expand Up @@ -154,7 +118,7 @@ router.post('/password', async (req, res) => {
*/
router.post('/userinfo', csrfCheck, sessionCheck, (req, res) => {
const { user } = res.locals;
user.rpId = process.env.HOSTNAME;
user.rpId = config.hostname;
return res.json(user);
});

Expand Down Expand Up @@ -200,7 +164,7 @@ router.post('/renameKey', csrfCheck, sessionCheck, async (req, res) => {
const { user } = res.locals;
const credential = await Credentials.findById(credId);
if (!user || user.id !== credential?.user_id) {
return res.status(401).json({ error: 'User not authorized.' });
return res.status(401).json({ message: 'User not authorized.' });
}
credential.name = newName;
await Credentials.update(credential);
Expand Down Expand Up @@ -245,8 +209,8 @@ router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {

// Use SimpleWebAuthn's handy function to create registration options.
const options = await generateRegistrationOptions({
rpName: process.env.RP_NAME,
rpID: process.env.HOSTNAME,
rpName: config.rp_name,
rpID: config.hostname,
userID: isoBase64URL.toBuffer(user.id),
userName: user.username,
userDisplayName: user.displayName || user.username,
Expand All @@ -264,7 +228,7 @@ router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
return res.json(options);
} catch (e) {
console.error(e);
return res.status(400).send({ error: e.message });
return res.status(400).send({ message: e.message });
}
});

Expand All @@ -274,8 +238,8 @@ router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
// Set expected values.
const expectedChallenge = req.session.challenge;
const expectedOrigin = getOrigin(req.get('User-Agent'));
const expectedRPID = process.env.HOSTNAME;
const expectedOrigin = config.associated_origins;
const expectedRPID = config.hostname;
const credential = req.body;

try {
Expand All @@ -297,8 +261,11 @@ router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
}

const {
credentialPublicKey,
credentialID,
publicKey: credentialPublicKey,
id: credentialID,
} = registrationInfo.credential;

const {
aaguid = '00000000-0000-0000-0000-000000000000',
credentialDeviceType,
} = registrationInfo;
Expand Down Expand Up @@ -335,7 +302,7 @@ router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
delete req.session.challenge;

console.error(e);
return res.status(400).send({ error: e.message });
return res.status(400).send({ message: e.message });
}
});

Expand All @@ -346,7 +313,7 @@ router.post('/signinRequest', csrfCheck, async (req, res) => {
try {
// Use SimpleWebAuthn's handy function to create a new authentication request.
const options = await generateAuthenticationOptions({
rpID: process.env.HOSTNAME,
rpID: config.hostname,
allowCredentials: [],
});

Expand All @@ -357,7 +324,7 @@ router.post('/signinRequest', csrfCheck, async (req, res) => {
} catch (e) {
console.error(e);

return res.status(400).json({ error: e.message });
return res.status(400).json({ message: e.message });
}
});

Expand All @@ -366,17 +333,21 @@ router.post('/signinRequest', csrfCheck, async (req, res) => {
*/
router.post('/signinResponse', csrfCheck, async (req, res) => {
// Set expected values.
const credential = req.body;
const response = req.body;
const expectedChallenge = req.session.challenge;
const expectedOrigin = getOrigin(req.get('User-Agent'));
const expectedRPID = process.env.HOSTNAME;
const expectedOrigin = config.associated_origins;
const expectedRPID = config.hostname;

try {

// Find the matching credential from the credential ID
const cred = await Credentials.findById(credential.id);
const cred = await Credentials.findById(response.id);
if (!cred) {
throw new Error('Matching credential not found on the server. Try signing in with a password.');
delete req.session.challenge;

const message = 'Matching credential not found. Try signing in with a password.';
console.error(message);
return res.status(404).json({ message });
}

// Find the matching user from the user ID contained in the credential.
Expand All @@ -386,19 +357,19 @@ router.post('/signinResponse', csrfCheck, async (req, res) => {
}

// Decode ArrayBuffers and construct an authenticator object.
const authenticator = {
credentialID: cred.id,
credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey),
const credential = {
id: cred.id,
publicKey: isoBase64URL.toBuffer(cred.publicKey),
transports: cred.transports,
};

// Use SimpleWebAuthn's handy function to verify the authentication request.
const verification = await verifyAuthenticationResponse({
response: credential,
response,
expectedChallenge,
expectedOrigin,
expectedRPID,
authenticator,
credential,
requireUserVerification: false,
});

Expand Down
21 changes: 1 addition & 20 deletions libs/db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License
*/
// import path from 'path';
// import url from 'url';
// import dotenv from 'dotenv';
import firebaseJson from '../firebase.json' assert { type: 'json' };
// const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
// dotenv.config({ path: path.join(__dirname, ".env") });
import { getFirestore } from 'firebase-admin/firestore';
import { initializeApp } from 'firebase-admin/app';

if (process.env.NODE_ENV === 'localhost') {
process.env.DOMAIN = 'http://localhost:8080';
process.env.GOOGLE_CLOUD_PROJECT = 'passkeys-demo';
process.env.FIRESTORE_EMULATOR_HOST = `${firebaseJson.emulators.firestore.host}:${firebaseJson.emulators.firestore.port}`;
} else if (process.env.NODE_ENV === 'development') {
process.env.DOMAIN = 'https://passkeys-demo.appspot.com';
}

initializeApp();
const store = getFirestore();
store.settings({ ignoreUndefinedProperties: true });
import { store } from '../config.js';

/**
* User data schema
Expand Down
Loading

0 comments on commit 9fa4e54

Please sign in to comment.