diff --git a/src/common/errors.ts b/src/common/errors.ts index 344309e..c48a787 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -14,47 +14,64 @@ export const StatusCodes: Record = { export class BaseError extends Error { status: number; - constructor(message: string, status: number) { + constructor(message: string, status: number, cause?: Error) { const statusText = StatusCodes[status] || "Unknown"; super(`${status} ${statusText}: ${message}`); this.name = this.constructor.name; this.status = status; + this.cause = cause; } } export class CancellationError extends BaseError { - constructor(reason: string) { - super(`${reason} was cancelled`, 499); + constructor(reason: string, cause?: Error) { + super(`${reason} was cancelled`, 499, cause); } } export class AuthError extends BaseError { - constructor(reason: string) { - super(`authentication failed: ${reason}`, 403); + constructor(reason: string, cause?: Error) { + super(`authentication failed: ${reason}`, 403, cause); } } export class ValidationError extends BaseError { - constructor(zodError: ZodError) { - super(`validation failed: ${prettifyError(zodError)}`, 400); + constructor(zodError: ZodError, cause?: Error) { + super(`validation failed: ${prettifyError(zodError)}`, 400, cause); } } export class NotFoundError extends BaseError { - constructor(resource: string) { - super(`${resource} not found`, 404); + constructor(resource: string, cause?: Error) { + super(`${resource} not found`, 404, cause); } } export class ConflictError extends BaseError { - constructor(reason: string) { - super(`conflict: ${reason}`, 409); + constructor(reason: string, cause?: Error) { + super(`conflict: ${reason}`, 409, cause); } } export class RateLimitError extends BaseError { - constructor(reason: string) { - super(`rate limit exceeded: ${reason}`, 429); + constructor(reason: string, cause?: Error) { + super(`rate limit exceeded: ${reason}`, 429, cause); + } +} + +export class NormalizedError extends Error { + value: unknown; + constructor(value: unknown, cause?: Error) { + super(cause?.message || String(value)); + this.value = value; + } +} + +export function normalizeError(value: unknown) { + if (value instanceof Error) { + return value; + } else { + return new NormalizedError(value); } } diff --git a/src/schema/crypto.ts b/src/schema/crypto.ts index 1871d50..0ef3a0b 100644 --- a/src/schema/crypto.ts +++ b/src/schema/crypto.ts @@ -1,15 +1,45 @@ -import { z } from "zod/v4"; +import { z, ZodError } from "zod/v4"; import { decodeJwt, importJWK, jwtVerify } from "@panva/jose"; import { JWTPayload, JWTVerifyResult } from "@panva/jose"; // application wide! const SIGNING_ALGO = "ES256"; +const CLOCK_TOLERANCE_S = 30; // tokens -export const jwtDecodedSchema = z.jwt({ abort: true }).transform((val) => { - return decodeJwt(val); +const jwtClaimsSchema = (now?: number) => { + now ??= Math.floor(Date.now() / 1000); + + return z.object({ + exp: z.number().gte(now - CLOCK_TOLERANCE_S, { error: "token expiration passed" }).optional(), + nbf: z.number().lte(now + CLOCK_TOLERANCE_S, { error: "token not-before in the future" }).optional(), + iat: z.number().lte(now + CLOCK_TOLERANCE_S, { error: "token issued-at in the future" }).optional(), + }); +}; + +// we just do a simple claims validation here +// don't forget to do a full signature validation at some point! +export const jwtDecodedSchema = z.jwt({ abort: true }).transform((val, ctx) => { + try { + const jwt = decodeJwt(val); + jwtClaimsSchema().parse(jwt); + + return jwt; + } catch (e) { + if (e instanceof ZodError) { + ctx.issues.push(...e.issues); + } else { + ctx.issues.push({ + code: "custom", + message: `error while doing claims validation: ${e}`, + input: val, + }); + } + + return z.NEVER; + } }); export const jwtVerifier = (pubkey: CryptoKey) => diff --git a/src/server/routes-socket/handler.ts b/src/server/routes-socket/handler.ts index bde3c00..2aba0fb 100644 --- a/src/server/routes-socket/handler.ts +++ b/src/server/routes-socket/handler.ts @@ -1,12 +1,19 @@ import { takeSocket } from "@repo/common/socket.ts"; import { combineSignals, timeoutSignal } from "@repo/common/aborts.ts"; -import { BaseError, CancellationError, ValidationError } from "@repo/common/errors.ts"; +import { + AuthError, + BaseError, + CancellationError, + normalizeError, + ValidationError, +} from "@repo/common/errors.ts"; import { z } from "zod/v4"; import { StrictMap } from "@repo/common/strict-map.ts"; -import { IdentId, RealmId } from "@repo/schema/state.ts"; +import { IdentId, parseIdentId, parseRealmId, RealmId } from "@repo/schema/state.ts"; import { jwtDecodedSchema } from "@repo/schema/crypto.ts"; -import { preauthMessageSchema } from "@repo/schema/proto.ts"; +import { preauthAuthnMessageSchema } from "@repo/schema/proto.ts"; +import { jwtVerify } from "@panva/jose/jwt/verify"; type Realm = { id: RealmId; @@ -14,7 +21,6 @@ type Realm = { identities: StrictMap; }; -/* const realmMap = new StrictMap(); function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { @@ -22,9 +28,8 @@ function attachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { } function detachSocket(realm: Realm, ident: IdentId, socket: WebSocket) { - realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []) + realm.sockets.update(ident, (ss) => ss ? ss.filter((s) => s !== socket) : []); } -*/ export async function socketHandler(this: WebSocket) { const controller = new AbortController(); @@ -34,8 +39,12 @@ export async function socketHandler(this: WebSocket) { })); try { - const jwt = await preauthHandler(this, controller.signal); - console.log(jwt); + const { realm, ...auth } = await preauthHandler(this, controller.signal); + try { + attachSocket(realm, auth.identid, this); + } finally { + detachSocket(realm, auth.identid, this); + } } catch (e) { errorHandler(this, e); } finally { @@ -50,11 +59,35 @@ async function preauthHandler(ws: WebSocket, signal: AbortSignal) { const timeout = timeoutSignal(3000); const combinedSignal = combineSignals(signal, timeout.signal); + // wait for preauth.authn message;it will be signed by the identity, + // which we already have if the realm exists, and we add to a new realm if being created try { const data = await takeSocket(ws, combinedSignal); - const schema = jwtDecodedSchema.pipe(preauthMessageSchema); + const jwt = jwtDecodedSchema.parse(data); + + const msg = await preauthAuthnMessageSchema.parseAsync(jwt); + const realmid = parseRealmId(jwt.aud); + const identid = parseIdentId(jwt.iss); + + // make sure we have a realm + // if a new one is being created, the current identity to it + const realm = realmMap.ensure(realmid, () => ({ + id: realmid, + sockets: new StrictMap(), + identities: new StrictMap([[identid, msg.pubkey]]), + })); + + try { + // we've looked up the realm + // pubkey should match what we've got stored, and the signature should be valid + const pubkey = realm.identities.require(identid); + await jwtVerify(data, pubkey); - return await schema.parseAsync(data); + // the signature is good + return { realm, realmid, identid, pubkey }; + } catch (e) { + throw new AuthError("jwt verification failed", normalizeError(e)); + } } finally { timeout.cleanup(); }