diff --git a/pages/client/[id]/callback.vue b/pages/client/[id]/callback.vue index 5da01fc..5cb3e10 100644 --- a/pages/client/[id]/callback.vue +++ b/pages/client/[id]/callback.vue @@ -134,13 +134,13 @@ import { CheckCircleIcon } from "@heroicons/vue/24/outline"; const route = useRoute(); const clientId = route.params.id; -const clientData = await useFetch(`/api/v1/client/callback?id=${clientId}`); +const clientData = await useFetch(`/api/v1/client/auth/callback?id=${clientId}`); const completed = ref(false); const error = ref(); async function authorize() { - const redirect = await $fetch("/api/v1/client/callback", { + const redirect = await $fetch("/api/v1/client/auth/callback", { method: "POST", body: { id: clientId }, }); diff --git a/server/api/v1/client/callback/index.get.ts b/server/api/v1/client/auth/callback/index.get.ts similarity index 100% rename from server/api/v1/client/callback/index.get.ts rename to server/api/v1/client/auth/callback/index.get.ts diff --git a/server/api/v1/client/callback/index.post.ts b/server/api/v1/client/auth/callback/index.post.ts similarity index 100% rename from server/api/v1/client/callback/index.post.ts rename to server/api/v1/client/auth/callback/index.post.ts diff --git a/server/api/v1/client/handshake.post.ts b/server/api/v1/client/auth/handshake.post.ts similarity index 100% rename from server/api/v1/client/handshake.post.ts rename to server/api/v1/client/auth/handshake.post.ts diff --git a/server/api/v1/client/initiate.post.ts b/server/api/v1/client/auth/initiate.post.ts similarity index 100% rename from server/api/v1/client/initiate.post.ts rename to server/api/v1/client/auth/initiate.post.ts diff --git a/server/api/v1/client/session.post.ts b/server/api/v1/client/auth/session.post.ts similarity index 100% rename from server/api/v1/client/session.post.ts rename to server/api/v1/client/auth/session.post.ts diff --git a/server/api/v1/client/user/index.get.ts b/server/api/v1/client/user/index.get.ts new file mode 100644 index 0000000..79ba08c --- /dev/null +++ b/server/api/v1/client/user/index.get.ts @@ -0,0 +1,7 @@ +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; + +export default defineClientEventHandler(async (h3, { fetchUser }) => { + const user = await fetchUser(); + + return user; +}); diff --git a/server/internal/clients/README.md b/server/internal/clients/README.md index 63f9770..7da5dbf 100644 --- a/server/internal/clients/README.md +++ b/server/internal/clients/README.md @@ -3,7 +3,7 @@ Drop clients need to complete a handshake in order to connect to a Drop server. It also trades certificates for encrypted P2P connections. ## 1. Client requests a handshake -Client makes request: `POST /api/v1/client/initiate` with information about the client. +Client makes request: `POST /api/v1/client/auth/initiate` with information about the client. Server responds with a URL to send the user to. It generates a device ID, which has all the metadata attached. @@ -13,7 +13,7 @@ Client sends user to the provided URL (in external browser). User signs in using Server sends redirect to `drop://handshake/[id]/[token]`, where the token is an authentication token to generate the necessary certificates, and the ID is the client ID as generated by the server. ## 3. Client requests certificates -Client makes request: `POST /api/v1/client/handshake` with the token recieved in the previous step. +Client makes request: `POST /api/v1/client/auth/handshake` with the token recieved in the previous step. The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all. @@ -23,4 +23,4 @@ The server uses it's CA to generate a public-private key pair, the CN of the cli The client generates a nonce and signs it with their private key. This is then attached to any device-related request. ## 4.b Client wants a long-lived session -The client does the same as above, but instead makes the request to `POST /api/v1/client/session`, which generates a session token that lasts for a day. This can then be used in the request to provide authentication. \ No newline at end of file +The client does the same as above, but instead makes the request to `POST /api/v1/client/auth/session`, which generates a session token that lasts for a day. This can then be used in the request to provide authentication. \ No newline at end of file diff --git a/server/internal/clients/ca.ts b/server/internal/clients/ca.ts index 5e5b1d1..5367d1a 100644 --- a/server/internal/clients/ca.ts +++ b/server/internal/clients/ca.ts @@ -51,4 +51,8 @@ export class CertificateAuthority { async storeClientCertificate(clientId: string, bundle: CertificateBundle) { await this.certificateStore.store(`client:${clientId}`, bundle); } + + async fetchClientCertificate(clientId: string) { + return await this.certificateStore.fetch(`client:${clientId}`); + } } diff --git a/server/internal/clients/event-handler.ts b/server/internal/clients/event-handler.ts new file mode 100644 index 0000000..357aca8 --- /dev/null +++ b/server/internal/clients/event-handler.ts @@ -0,0 +1,96 @@ +import { Client, User } from "@prisma/client"; +import { EventHandlerRequest, H3Event } from "h3"; +import droplet from "@drop/droplet"; +import { useGlobalCertificateAuthority } from "~/server/plugins/ca"; +import prisma from "../db/database"; + +export type EventHandlerFunction = ( + h3: H3Event, + utils: ClientUtils +) => Promise | T; + +type ClientUtils = { + clientId: string; + fetchClient: () => Promise; + fetchUser: () => Promise; +}; + +export function defineClientEventHandler(handler: EventHandlerFunction) { + return defineEventHandler(async (h3) => { + const header = await getHeader(h3, "Authorization"); + if (!header) throw createError({ statusCode: 403 }); + const [method, ...parts] = header.split(" "); + + let clientId: string; + switch (method) { + case "Nonce": + clientId = parts[0]; + const nonce = parts[1]; + const signature = parts[2]; + + if (!clientId || !nonce || !signature) + throw createError({ statusCode: 403 }); + + const ca = useGlobalCertificateAuthority(); + const certBundle = await ca.fetchClientCertificate(clientId); + if (!certBundle) + throw createError({ + statusCode: 403, + statusMessage: "Invalid client ID", + }); + + const valid = droplet.verifyNonce(certBundle.cert, nonce, signature); + if (!valid) + throw createError({ + statusCode: 403, + statusMessage: "Invalid nonce signature.", + }); + break; + default: + throw createError({ + statusCode: 403, + }); + } + + if (clientId === undefined) + throw createError({ + statusCode: 500, + statusMessage: "Failed to execute authentication pipeline.", + }); + + async function fetchClient() { + const client = await prisma.client.findUnique({ + where: { id: clientId }, + }); + if (!client) + throw new Error( + "client util fetch client broke - this should NOT happen" + ); + return client; + } + + async function fetchUser() { + const client = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + user: true, + }, + }); + + if (!client) + throw new Error( + "client util fetch client broke - this should NOT happen" + ); + + return client.user; + } + + const utils: ClientUtils = { + clientId, + fetchClient, + fetchUser, + }; + + return await handler(h3, utils); + }); +}