From 37dd24f428133d48070b2c2e561f32bf56dc84c3 Mon Sep 17 00:00:00 2001 From: sebipap Date: Tue, 6 Feb 2024 14:33:55 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82=20server:=20implement=20passkey=20?= =?UTF-8?q?authentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/generate-authentication-options.ts | 19 ++---- .../api/auth/generate-registration-options.ts | 27 +++------ server/api/auth/verify-authentication.ts | 22 ++++--- server/api/auth/verify-registration.ts | 39 ++++--------- server/api/card.ts | 30 ++++++++++ server/api/user.ts | 31 ++++++++++ server/utils/auth.ts | 58 ++++++++++++++----- server/utils/card.ts | 11 ++-- server/utils/cors.ts | 9 ++- server/utils/request.ts | 11 +++- server/utils/rpId.ts | 5 ++ server/utils/user.ts | 25 +++++--- 12 files changed, 185 insertions(+), 102 deletions(-) create mode 100644 server/api/card.ts create mode 100644 server/api/user.ts create mode 100644 server/utils/rpId.ts diff --git a/server/api/auth/generate-authentication-options.ts b/server/api/auth/generate-authentication-options.ts index 76b6d53df..40f8da572 100644 --- a/server/api/auth/generate-authentication-options.ts +++ b/server/api/auth/generate-authentication-options.ts @@ -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); } diff --git a/server/api/auth/generate-registration-options.ts b/server/api/auth/generate-registration-options.ts index e6910e4c3..abe4f497d 100644 --- a/server/api/auth/generate-registration-options.ts +++ b/server/api/auth/generate-registration-options.ts @@ -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); } diff --git a/server/api/auth/verify-authentication.ts b/server/api/auth/verify-authentication.ts index cb6a94c62..16fb3d4bb 100644 --- a/server/api/auth/verify-authentication.ts +++ b/server/api/auth/verify-authentication.ts @@ -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 }); @@ -45,7 +46,7 @@ async function handler(request: VercelRequest, response: VercelResponse) { const options: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge, - expectedOrigin: origin, + expectedOrigin: ORIGIN, expectedRPID: rpId, authenticator: databaseAuthenticator, requireUserVerification: false, @@ -53,7 +54,6 @@ async function handler(request: VercelRequest, response: VercelResponse) { verification = await verifyAuthenticationResponse(options); } catch (error) { const _error = error as Error; - console.error(_error); response.status(400).send({ error: _error.message }); return; } @@ -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); diff --git a/server/api/auth/verify-registration.ts b/server/api/auth/verify-registration.ts index 1578169a7..876db57f9 100644 --- a/server/api/auth/verify-registration.ts +++ b/server/api/auth/verify-registration.ts @@ -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; @@ -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, }; @@ -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 }); } diff --git a/server/api/card.ts b/server/api/card.ts new file mode 100644 index 000000000..00695a2a0 --- /dev/null +++ b/server/api/card.ts @@ -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)); diff --git a/server/api/user.ts b/server/api/user.ts new file mode 100644 index 000000000..f35710b5b --- /dev/null +++ b/server/api/user.ts @@ -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)); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 27911f55f..fb5286c10 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -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), @@ -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) => + 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); + }; diff --git a/server/utils/card.ts b/server/utils/card.ts index 3bd73b330..9f336804f 100644 --- a/server/utils/card.ts +++ b/server/utils/card.ts @@ -1,21 +1,22 @@ import request from "./request.js"; import type { Card, CreateCardRequest, Paginated, User } from "./types.js"; -import { card as cardSchema, paginated } from "./types.js"; +import { card as cardSchema, paginated, responseData } from "./types.js"; export function getCard(id: Card["id"]) { return request(`/cards/v1/${id}`, { method: "GET" }, cardSchema); } -export function getCardByUserID(userId: User["id"]) { - return request>( +export async function getCardsByUserID(userId: User["id"]) { + const response = await request>( `/cards/v1?filter[user_id]=${userId}`, { method: "GET" }, paginated(cardSchema), ); + return response.data; } export function createCard(card: CreateCardRequest) { - return request( + return request<{ data: Card }>( "/cards/v1", { method: "POST", @@ -24,6 +25,6 @@ export function createCard(card: CreateCardRequest) { "x-idempotency-key": card.user_id, // TODO use a real idempotency key }, }, - cardSchema, + responseData(cardSchema), ); } diff --git a/server/utils/cors.ts b/server/utils/cors.ts index 9c546a6cc..6c3e27819 100644 --- a/server/utils/cors.ts +++ b/server/utils/cors.ts @@ -1,13 +1,12 @@ import type { VercelApiHandler, VercelRequest, VercelResponse } from "@vercel/node"; +import { ORIGIN } from "./auth"; + const allowCors = (function_: VercelApiHandler) => async (request: VercelRequest, response: VercelResponse) => { response.setHeader("Access-Control-Allow-Credentials", "true"); - response.setHeader("Access-Control-Allow-Origin", "http://localhost:8081"); + response.setHeader("Access-Control-Allow-Origin", ORIGIN); response.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS,PATCH,DELETE,POST,PUT"); - response.setHeader( - "Access-Control-Allow-Headers", - "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type", - ); + response.setHeader("Access-Control-Allow-Headers", "*, Authorization"); if (request.method === "OPTIONS") { response.status(200).end(); return; diff --git a/server/utils/request.ts b/server/utils/request.ts index e5aeb652a..7a3b900ee 100644 --- a/server/utils/request.ts +++ b/server/utils/request.ts @@ -41,7 +41,7 @@ export default async function request( }); const response = await fetch(_request); - const json = response.json(); + const json: unknown = await response.json(); if (!response.ok) { const { @@ -68,5 +68,12 @@ export default async function request( } } - return validator.parse(json); + try { + return validator.parse(json); + } catch { + // TODO review with team + // don't throw when validation fails? + // maybe just report to sentry? + return json as Response; + } } diff --git a/server/utils/rpId.ts b/server/utils/rpId.ts new file mode 100644 index 000000000..277694d52 --- /dev/null +++ b/server/utils/rpId.ts @@ -0,0 +1,5 @@ +// TODO check if we could use value from utils/contants.ts instead +// Error: Named export 'rpId' not found. The requested module '../../../utils/constants.js' is a CommonJS module, which may not support all module.exports as named exports. +// also, rpID depends on the environment, so it's better to keep it in a separate file? + +export default "localhost"; diff --git a/server/utils/user.ts b/server/utils/user.ts index db1e9eb01..ff7eeb0ab 100644 --- a/server/utils/user.ts +++ b/server/utils/user.ts @@ -1,15 +1,24 @@ import request from "./request.js"; import type { CreateUserRequest, User } from "./types.js"; -import { user as userSchema } from "./types.js"; +import { paginated, responseData, user as userSchema } from "./types.js"; export async function getUser(userId: User["id"]) { - try { - return request(`/users/v1/${userId}`, { method: "GET" }, userSchema); - } catch { - // couldn't find user, return undefined - } + return request<{ data: User }>(`/users/v1/${userId}`, { method: "GET" }, responseData(userSchema)); } -export function createUser(user: CreateUserRequest) { - return request("/users/v1", { method: "POST", body: user }, userSchema); +export async function getUserByEmail(email: User["email"]) { + const response = await request<{ data: User[] }>( + `/users/v1/?filter[email]=${email}`, + { method: "GET" }, + paginated(userSchema), + ); + return response.data[0]; +} + +export async function getUserByCredentialID(credentialID: string) { + return getUserByEmail(`${credentialID}@exactly.account`); +} + +export async function createUser(user: CreateUserRequest) { + return request<{ data: User }>("/users/v1", { method: "POST", body: user }, responseData(userSchema)); }