Skip to content

Commit

Permalink
πŸ›‚ server: implement passkey authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
sebipap authored and cruzdanilo committed Jun 10, 2024
1 parent 2feb813 commit 37dd24f
Show file tree
Hide file tree
Showing 12 changed files with 185 additions and 102 deletions.
19 changes: 6 additions & 13 deletions server/api/auth/generate-authentication-options.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import type { GenerateAuthenticationOptionsOpts } from "@simplewebauthn/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import type { VercelRequest, VercelResponse } from "@vercel/node";

import { rpId } from "../../../utils/constants.js";
import { getCredentials, saveChallenge } from "../../utils/auth.js";
import { saveChallenge } from "../../utils/auth.js";
import allowCors from "../../utils/cors.js";
import rpId from "../../utils/rpId.js";

async function handler(request: VercelRequest, response: VercelResponse) {
const { userID } = request.query as { userID: string };
const credentials = await getCredentials(userID);
const options_: GenerateAuthenticationOptionsOpts = {
const { challengeID } = request.query as { challengeID: string };
const options = await generateAuthenticationOptions({
timeout: 60_000,
allowCredentials: credentials.map((credential) => ({
...credential,
type: "public-key",
})),
userVerification: "preferred",
rpID: rpId,
};
const options = await generateAuthenticationOptions(options_);
await saveChallenge({ challenge: options.challenge, userID });
});
await saveChallenge({ challenge: options.challenge, challengeID });
response.send(options);
}

Expand Down
27 changes: 8 additions & 19 deletions server/api/auth/generate-registration-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,30 @@ import type { GenerateRegistrationOptionsOpts } from "@simplewebauthn/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import type { VercelRequest, VercelResponse } from "@vercel/node";

import { rpId } from "../../../utils/constants.js";
import { getCredentials, saveChallenge } from "../../utils/auth.js";
import { saveChallenge } from "../../utils/auth.js";
import allowCors from "../../utils/cors.js";
import rpId from "../../utils/rpId.js";

const ES256 = -7;

async function handler(request: VercelRequest, response: VercelResponse) {
const { userID } = request.query as { userID: string };
const credentials = await getCredentials(userID);
const { challengeID } = request.query as { challengeID: string };
const registrationOptions: GenerateRegistrationOptionsOpts = {
rpName: "exactly",
rpID: rpId,
userID,
userID: challengeID,
// TODO change username
userName: "username",
timeout: 60_000,
attestationType: "none",
/**
* Passing in a user's list of already-registered authenticator IDs here prevents users from
* registering the same device multiple times. The authenticator will simply throw an error in
* the browser if it's asked to perform registration when one of these ID's already resides
* on it.
*/
excludeCredentials: credentials.map((credential) => ({
...credential,
type: "public-key",
})),
authenticatorSelection: {
residentKey: "discouraged",
userVerification: "preferred",
},
// TODO check if this are the correct values
supportedAlgorithmIDs: [-7, -257],
supportedAlgorithmIDs: [ES256],
};

const options = await generateRegistrationOptions(registrationOptions);
await saveChallenge({ challenge: options.challenge, userID });
await saveChallenge({ challenge: options.challenge, challengeID });
response.send(options);
}

Expand Down
22 changes: 14 additions & 8 deletions server/api/auth/verify-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ import {
import { isoBase64URL, isoUint8Array } from "@simplewebauthn/server/helpers";
import type { AuthenticationResponseJSON } from "@simplewebauthn/types";
import type { VercelRequest, VercelResponse } from "@vercel/node";
import jwt from "jsonwebtoken";

import { rpId } from "../../../utils/constants.js";
import { getChallenge, getCredentials, origin } from "../../utils/auth.js";
import { base64URLEncode, getChallenge, getCredentialsByID, ORIGIN } from "../../utils/auth.js";
import allowCors from "../../utils/cors.js";
import rpId from "../../utils/rpId.js";

async function handler(request: VercelRequest, response: VercelResponse) {
const body = request.body as AuthenticationResponseJSON;
const { userID } = request.query as { userID: string };
const credentials = await getCredentials(userID);
const challenge = await getChallenge(userID);
const { challengeID } = request.query as { challengeID: string };
const credentials = await getCredentialsByID(body.id);
const challenge = await getChallenge(challengeID);

if (!challenge) {
response.send({ verified: false });
Expand Down Expand Up @@ -45,15 +46,14 @@ async function handler(request: VercelRequest, response: VercelResponse) {
const options: VerifyAuthenticationResponseOpts = {
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedOrigin: ORIGIN,
expectedRPID: rpId,
authenticator: databaseAuthenticator,
requireUserVerification: false,
};
verification = await verifyAuthenticationResponse(options);
} catch (error) {
const _error = error as Error;
console.error(_error);
response.status(400).send({ error: _error.message });
return;
}
Expand All @@ -63,7 +63,13 @@ async function handler(request: VercelRequest, response: VercelResponse) {
if (verified) {
databaseAuthenticator.counter = authenticationInfo.newCounter;
}
response.send({ verified });
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET not set");
}
const token = jwt.sign({ credentialID: base64URLEncode(authenticationInfo.credentialID) }, process.env.JWT_SECRET, {
expiresIn: "24h",
});
response.send({ verified, token });
}

export default allowCors(handler);
39 changes: 12 additions & 27 deletions server/api/auth/verify-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,18 @@ import {
type VerifiedRegistrationResponse,
type VerifyRegistrationResponseOpts,
} from "@simplewebauthn/server";
import { isoUint8Array } from "@simplewebauthn/server/helpers";
import type { AuthenticatorDevice, RegistrationResponseJSON } from "@simplewebauthn/types";
import type { RegistrationResponseJSON } from "@simplewebauthn/types";
import type { VercelRequest, VercelResponse } from "@vercel/node";

import { rpId } from "../../../utils/constants.js";
import { getChallenge, getCredentials, saveCredentials } from "../../utils/auth.js";
import { getChallenge, ORIGIN, saveCredentials } from "../../utils/auth.js";
import allowCors from "../../utils/cors.js";
import rpId from "../../utils/rpId.js";

async function handler(request: VercelRequest, response: VercelResponse) {
const body = request.body as RegistrationResponseJSON;
const { challengeID } = request.query as { challengeID: string };

const { userID } = request.query as { userID: string };

const credentials = await getCredentials(userID);

const challenge = await getChallenge(userID);

const challenge = await getChallenge(challengeID);
if (!challenge) {
response.send({ verified: false });
return;
Expand All @@ -32,7 +27,7 @@ async function handler(request: VercelRequest, response: VercelResponse) {
const options: VerifyRegistrationResponseOpts = {
response: body,
expectedChallenge,
expectedOrigin: "http://localhost:8081",
expectedOrigin: ORIGIN,
expectedRPID: rpId,
requireUserVerification: false,
};
Expand All @@ -43,28 +38,18 @@ async function handler(request: VercelRequest, response: VercelResponse) {
response.status(400).send({ error: _error.message });
return;
}

const { verified, registrationInfo } = verification;

if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter } = registrationInfo;

const existingDevice = credentials.find((credential) => isoUint8Array.areEqual(credential.id, credentialID));

if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
const newDevice: AuthenticatorDevice = {
credentialPublicKey,
credentialID,
counter,
transports: body.response.transports,
};
await saveCredentials({ ...newDevice, userID });
}
await saveCredentials({
credentialPublicKey,
credentialID,
counter,
transports: body.response.transports,
});
}

response.send({ verified });
}

Expand Down
30 changes: 30 additions & 0 deletions server/api/card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";

import { authenticated } from "../utils/auth.js";
import { createCard, getCardsByUserID } from "../utils/card.js";
import allowCors from "../utils/cors.js";
import { getUserByCredentialID } from "../utils/user.js";

async function handler(request: VercelRequest, response: VercelResponse, credentialID: string) {
const user = await getUserByCredentialID(credentialID);
if (!user) {
response.status(404).end("User not found");
return;
}
if (request.method === "POST") {
try {
const card = await createCard({
user_id: user.id,
card_type: "VIRTUAL",
affinity_group_id: "afg-2VfIFzzjDX9eRD2VVgmKnB6YmWm", // TODO use env. note: we'll probably use the same affinity group for all cards
});
response.status(200).json(card);
} catch (error) {
response.status(400).end(error instanceof Error ? error.message : "There was an error");
}
} else if (request.method === "GET") {
const cards = await getCardsByUserID(user.id);
response.status(200).json(cards);
}
}
export default allowCors(authenticated(handler));
31 changes: 31 additions & 0 deletions server/api/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";

import { authenticated } from "../utils/auth.js";
import allowCors from "../utils/cors.js";
import type { CreateUserForm } from "../utils/types.js";
import { createUser, getUserByCredentialID } from "../utils/user.js";

async function handler(request: VercelRequest, response: VercelResponse, credentialID: string) {
if (request.method === "POST") {
try {
const body = request.body as CreateUserForm; // TODO validate request body
const user = await createUser({
...body,
operation_country: "MEX", // TODO only for sandbox. For prod will be "PER"
email: `${credentialID}@exactly.account`,
});
response.status(200).json(user);
} catch (error) {
response.status(400).end(error instanceof Error ? error.message : "Unknown error");
}
} else if (request.method === "GET") {
const user = await getUserByCredentialID(credentialID);
if (user) {
response.status(200).json(user);
} else {
response.status(404).end("User not found");
}
}
}

export default allowCors(authenticated(handler));
58 changes: 43 additions & 15 deletions server/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,58 @@
import type { AuthenticatorDevice, AuthenticatorTransportFuture } from "@simplewebauthn/types";
import type {
AuthenticatorDevice,
AuthenticatorTransportFuture,
Base64URLString,
} from "@simplewebauthn/typescript-types";
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { encode, decode } from "base64-arraybuffer";
import { eq } from "drizzle-orm";
import jwt from "jsonwebtoken";

import database from "../database/index.js";
import { credential as credentialSchema, challenge as challengeSchema } from "../database/schema.js";

export const origin = "http://localhost:8081"; // TODO change this
type TokenPayload = {
credentialID: string;
iat: number;
exp: number;
};

export const ORIGIN = "http://localhost:8081"; // TODO this works for local development using web. Check what would be the origin for mobile

// TODO use function in /utils. for now it was not working
function base64URLEncode(buffer: ArrayBufferLike) {
export function base64URLEncode(buffer: ArrayBufferLike) {
return encode(buffer).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}

function base64URLDecode(text: string) {
// TODO use function in /utils. for now it was not working
export function base64URLDecode(text: string) {
const buffer = decode(text.replaceAll("-", "+").replaceAll("_", "/"));
return new Uint8Array(buffer);
}

export async function saveChallenge({ userID, challenge }: { challenge: string; userID: string }) {
await database.delete(challengeSchema).where(eq(challengeSchema.userID, userID));
await database.insert(challengeSchema).values([{ userID, value: challenge }]);
export async function saveChallenge({ challengeID, challenge }: { challenge: Base64URLString; challengeID: string }) {
await database.delete(challengeSchema).where(eq(challengeSchema.id, challengeID));
await database.insert(challengeSchema).values([{ id: challengeID, value: challenge }]);
}

export async function getChallenge(userID: string) {
const [challenge] = await database.select().from(challengeSchema).where(eq(challengeSchema.userID, userID));
await database.delete(challengeSchema).where(eq(challengeSchema.userID, userID));
export async function getChallenge(challengeID: string) {
const [challenge] = await database.select().from(challengeSchema).where(eq(challengeSchema.id, challengeID));
await database.delete(challengeSchema).where(eq(challengeSchema.id, challengeID));
return challenge;
}

export async function saveCredentials(credential: AuthenticatorDevice & { userID: string }) {
export async function saveCredentials(credential: AuthenticatorDevice) {
await database.insert(credentialSchema).values([
{
credentialID: base64URLEncode(credential.credentialID),
transports: credential.transports,
userID: credential.userID,
credentialPublicKey: base64URLEncode(credential.credentialPublicKey).toString(),
counter: credential.counter.toString(),
},
]);
}

export async function getCredentials(userID: string) {
const credentials = await database.select().from(credentialSchema).where(eq(credentialSchema.userID, userID));
export async function getCredentialsByID(id: string) {
const credentials = await database.select().from(credentialSchema).where(eq(credentialSchema.credentialID, id));
return credentials.map((cred) => ({
...cred,
id: base64URLDecode(cred.credentialID),
Expand All @@ -51,3 +62,20 @@ export async function getCredentials(userID: string) {
counter: Number(cred.counter),
}));
}

export const authenticated =
(handler: (request: VercelRequest, response: VercelResponse, userID: string) => Promise<void>) =>
async (request: VercelRequest, response: VercelResponse) => {
const { authorization } = request.headers;
if (!authorization) {
response.status(400).end("No token provided");
return;
}
const token = authorization.split("Bearer ")[1];
if (!token) {
response.status(400).end("No token provided");
return;
}
const { credentialID } = jwt.decode(token) as TokenPayload;
await handler(request, response, credentialID);
};
Loading

0 comments on commit 37dd24f

Please sign in to comment.