Skip to content

Commit d3f00fa

Browse files
authored
feat: Implement WebAuthn-based approval (#24)
- Closes #13 - Closes #17
1 parent d3f81be commit d3f00fa

File tree

5 files changed

+133
-35
lines changed

5 files changed

+133
-35
lines changed

apps/webapp/src/app/app/packages/[packageId]/requests/[requestId]/page.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,24 @@ export default function ApprovalRequestDetails(props: Props) {
2020
packageId: parseInt(params.packageId),
2121
});
2222
const startApproval = trpc.approvals.startApprovalProcess.useMutation();
23+
const approveRequest = trpc.approvals.approveRequest.useMutation();
2324

2425
const handleApprove = async () => {
2526
setIsApproving(true);
2627
try {
2728
const { options } = await startApproval.mutateAsync({
2829
requestId: parseInt(params.requestId),
2930
});
30-
const result = await startAuthentication(options);
31+
const result = await startAuthentication({
32+
optionsJSON: options,
33+
});
34+
console.log("result", result);
3135
alert("Approval submitted successfully!");
36+
37+
await approveRequest.mutateAsync({
38+
requestId: parseInt(params.requestId),
39+
result,
40+
});
3241
void refetch();
3342
} catch (error) {
3443
console.error("Error submitting approval:", error);

apps/webapp/src/server/procedures/approvalProcedures.ts

+102-17
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import {
1010
approvalAuthenticators,
1111
usersTable,
1212
} from "../schema";
13-
import { eq, and, Simplify } from "drizzle-orm";
13+
import { eq, and } from "drizzle-orm";
1414
import { TRPCError } from "@trpc/server";
1515
import { db } from "../db";
16-
import { startAuthentication } from "@simplewebauthn/browser";
17-
import { generateAuthenticationOptions } from "@simplewebauthn/server";
18-
import { isoUint8Array } from "@simplewebauthn/server/helpers";
16+
import {
17+
generateAuthenticationOptions,
18+
verifyAuthenticationResponse,
19+
} from "@simplewebauthn/server";
20+
import { isoBase64URL, isoUint8Array } from "@simplewebauthn/server/helpers";
21+
import { fromBase64URLString } from "./deviceProcedures";
1922

2023
const ApprovalRequestStatus = z.enum(["pending", "approved", "rejected"]);
2124

@@ -78,18 +81,19 @@ export const approvalProcedures = router({
7881
.from(approvalAuthenticators)
7982
.where(eq(approvalAuthenticators.userId, ctx.user.id));
8083

81-
if (authenticators.length === 0)
84+
if (authenticators.length === 0) {
8285
throw new TRPCError({
8386
code: "NOT_FOUND",
8487
message: "WebAuthn is not configured for this account",
8588
});
89+
}
8690

8791
const options = await generateAuthenticationOptions({
8892
rpID: process.env.WEBAUTHN_RP_ID!,
8993
allowCredentials: authenticators.map((authenticator) => ({
90-
id: authenticator.credentialID,
94+
id: fromBase64URLString(authenticator.credentialID),
9195
})),
92-
challenge: isoUint8Array.fromUTF8String(input.requestId.toString()),
96+
challenge: `approval-request:${input.requestId.toString()}`,
9397
timeout: 60000,
9498
userVerification: "required",
9599
});
@@ -212,34 +216,115 @@ export const approvalProcedures = router({
212216
}),
213217

214218
approveRequest: protectedProcedure
215-
.input(z.object({ requestId: z.number() }))
219+
.input(z.object({ requestId: z.number(), result: z.any() }))
216220
.mutation(async ({ input, ctx }) => {
221+
const dbCredentialId = Buffer.from(input.result.id).toString("base64url");
222+
223+
const [authenticator] = await db
224+
.select()
225+
.from(approvalAuthenticators)
226+
.where(
227+
and(
228+
eq(approvalAuthenticators.userId, ctx.user.id),
229+
eq(approvalAuthenticators.credentialID, dbCredentialId),
230+
),
231+
)
232+
.limit(1);
233+
if (!authenticator)
234+
throw new TRPCError({
235+
code: "FORBIDDEN",
236+
message: "Authenticator not found",
237+
});
238+
239+
const verificationResult = await verifyAuthenticationResponse({
240+
response: input.result,
241+
expectedChallenge: isoBase64URL.fromUTF8String(
242+
`approval-request:${input.requestId.toString()}`,
243+
),
244+
expectedOrigin: process.env.WEBAUTHN_ORIGIN!,
245+
expectedRPID: process.env.WEBAUTHN_RP_ID!,
246+
credential: {
247+
id: fromBase64URLString(authenticator.credentialID),
248+
publicKey: isoBase64URL.toBuffer(
249+
authenticator.credentialPublicKey,
250+
"base64url",
251+
),
252+
counter: authenticator.counter,
253+
},
254+
}).catch((e) => {
255+
console.error(e);
256+
throw new TRPCError({
257+
code: "FORBIDDEN",
258+
message: "Invalid authentication",
259+
});
260+
});
261+
262+
if (!verificationResult.verified) {
263+
throw new TRPCError({
264+
code: "FORBIDDEN",
265+
message: "Invalid authentication",
266+
});
267+
}
268+
269+
const [approvalGroup] = await db
270+
.select({
271+
id: approvalGroupsTable.id,
272+
})
273+
.from(approvalGroupsTable)
274+
.innerJoin(
275+
approvalGroupMembersTable,
276+
eq(approvalGroupsTable.id, approvalGroupMembersTable.groupId),
277+
)
278+
.where(eq(approvalGroupMembersTable.userId, ctx.user.id))
279+
.limit(1);
280+
281+
if (!approvalGroup) {
282+
throw new TRPCError({
283+
code: "NOT_FOUND",
284+
message: "Approval group not found",
285+
});
286+
}
287+
217288
// Add the user's approval
218289
await db
219290
.insert(approvalsTable)
220-
.values({ requestId: input.requestId, userId: ctx.user.id });
291+
.values({
292+
requestId: input.requestId,
293+
groupId: approvalGroup.id,
294+
userId: ctx.user.id,
295+
})
296+
.onConflictDoNothing();
221297

222298
// Check if the request should be approved
223-
const request = await db
299+
const [request] = await db
224300
.select()
225301
.from(approvalRequestsTable)
226302
.where(eq(approvalRequestsTable.id, input.requestId))
227303
.limit(1);
228304

305+
if (!request) {
306+
throw new TRPCError({
307+
code: "NOT_FOUND",
308+
message: "Request not found",
309+
});
310+
}
311+
229312
const approvalGroups = await db
230313
.select()
231314
.from(approvalGroupsTable)
232-
.where(eq(approvalGroupsTable.packageId, request[0].packageId));
315+
.where(eq(approvalGroupsTable.packageId, request.packageId));
233316

317+
//
234318
const approvedGroups = await db
235-
.select()
236-
.from(approvalsTable)
319+
.select({ id: approvalGroupsTable.id })
320+
.from(approvalGroupsTable)
237321
.innerJoin(
238-
approvalGroupMembersTable,
239-
eq(approvalsTable.userId, approvalGroupMembersTable.userId),
322+
approvalsTable,
323+
eq(approvalGroupsTable.id, approvalsTable.groupId),
240324
)
241-
.where(eq(approvalsTable.requestId, input.requestId))
242-
.groupBy(approvalGroupMembersTable.groupId);
325+
.where(eq(approvalsTable.requestId, input.requestId));
326+
console.log("approvedGroups", approvedGroups);
327+
console.log("approvalGroups", approvalGroups);
243328

244329
if (approvedGroups.length === approvalGroups.length) {
245330
// All groups have at least one approval, update the request status

apps/webapp/src/server/procedures/deviceProcedures.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,6 @@ export const deviceProcedures = router({
155155
}),
156156
});
157157

158-
function fromBase64URLString(credentialID: string): any {
158+
export function fromBase64URLString(credentialID: string): string {
159159
return Buffer.from(credentialID, "base64url").toString();
160160
}

apps/webapp/src/server/procedures/packageProcedures.ts

+17-16
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ try {
3333
credential: cert(googleCredentials),
3434
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
3535
});
36-
} catch (e) {
37-
console.error(e);
38-
}
36+
} catch (e) {}
3937

4038
export const packageProcedures = router({
4139
getPackages: protectedProcedure
@@ -82,14 +80,26 @@ export const packageProcedures = router({
8280
createPackage: protectedProcedure
8381
.input(z.object({ name: z.string() }))
8482
.mutation(async ({ input, ctx }) => {
85-
const newPackage = await db
83+
const [newPackage] = await db
8684
.insert(packagesTable)
8785
.values({ ...input, ownerId: ctx.user.id })
8886
.returning();
8987
await db
9088
.insert(packageMembersTable)
91-
.values({ packageId: newPackage[0].id, userId: ctx.user.id });
92-
return newPackage[0];
89+
.values({ packageId: newPackage.id, userId: ctx.user.id });
90+
91+
const [approvalGroup] = await db
92+
.insert(approvalGroupsTable)
93+
.values({
94+
packageId: newPackage.id,
95+
name: "(Default)",
96+
})
97+
.returning();
98+
await db
99+
.insert(approvalGroupMembersTable)
100+
.values({ groupId: approvalGroup.id, userId: ctx.user.id });
101+
102+
return newPackage;
93103
}),
94104

95105
getPackageMembers: protectedProcedure
@@ -219,21 +229,12 @@ export const packageProcedures = router({
219229
},
220230
webpush: {
221231
fcmOptions: {
222-
link: `https://tpp.dudy.dev/app/packages/${input.packageId}/requests/${request.id}`,
232+
link: `${process.env.NEXTAUTH_URL}/app/packages/${input.packageId}/requests/${request.id}`,
223233
},
224234
},
225235
tokens: devices.map((device) => device.fcmToken),
226236
};
227237

228-
try {
229-
await initializeApp({
230-
credential: googleCredentials,
231-
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
232-
});
233-
} catch (e) {
234-
console.error(e);
235-
}
236-
237238
const pushResults = await getMessaging().sendEachForMulticast(message);
238239
console.log("pushResults", pushResults);
239240

apps/webapp/src/server/schema.ts

+3
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ export const approvalsTable = pgTable(
223223
requestId: integer("request_id")
224224
.references(() => approvalRequestsTable.id)
225225
.notNull(),
226+
groupId: integer("group_id")
227+
.references(() => approvalGroupsTable.id, { onDelete: "cascade" })
228+
.notNull(),
226229
userId: text("user_id")
227230
.references(() => usersTable.id)
228231
.notNull(),

0 commit comments

Comments
 (0)