Skip to content

Commit

Permalink
Finalize authenticate middleware (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
samu authored Nov 29, 2024
1 parent 5f096bc commit fbe241c
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createPipeline } from "../../../createPipeline/createPipeline";
import { BasePipelineContext } from "../../../createPipeline/helpers/types";
import { authenticate } from "../../../middlewares/authenticate/authenticate";
import { AuthenticateCallbackResult } from "../../../middlewares/authenticate/helpers/types";
import { checkAcceptHeader } from "../../../middlewares/checkAcceptHeader/checkAcceptHeader";
import { deserialize } from "../../../middlewares/deserialize/deserialize";
import { finalize } from "../../../middlewares/finalize/finalize";
Expand All @@ -14,13 +16,26 @@ const defaultValidationErrorHandler = (validationErrors: ValidationErrors) => ({
body: validationErrors,
});

type AppSession = { user: { email: string } };

async function getSession<In extends BasePipelineContext>(
context: In
): Promise<AuthenticateCallbackResult<AppSession>> {
const authorization = context.request.getHeader("authorization");

return authorization === null
? { type: "unset" }
: { type: "set", session: { user: { email: atob(authorization) } } };
}

// TODO add integration tests for cors mw
// TODO add integration tests for authenticate

export const testPipeline = createValidatedEndpointFactory(
createPipeline(
checkAcceptHeader(),
deserialize(),
authenticate(),
authenticate(getSession),
withValidation()
).split(
handleValidationErrors(defaultValidationErrorHandler),
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/testHelpers/mockEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function mockEndpoint<
...(body instanceof FormData
? { "content-type": "multipart/form-data" }
: {}),
authorization: "c29tZUBlbWFpbC5jb20=",
} as Record<string, string>;

const handleRequest = createRequestHandler([
Expand All @@ -86,7 +87,7 @@ export function mockEndpoint<
fullUrl: fullUrl as string,
path: url.pathname,
query: url.search,
getHeader: (name) => headers[name],
getHeader: (name) => headers[name] ?? null,
readText: async () => body as string,
readFormData: async () => body as FormData,
});
Expand Down
59 changes: 59 additions & 0 deletions src/middlewares/authenticate/authenticate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { createTestContext } from "../../helpers/testHelpers/createTestContext";
import { authenticate } from "./authenticate";

const mw = authenticate(async (context) => {
const authorization = context.request.getHeader("authorization");

return authorization === null
? { type: "unset" }
: { type: "set", session: authorization };
});

describe("authenticate", () => {
it("sets the session returned by handler", async () => {
const result = await mw(
createTestContext({
requestHeaders: { authorization: "some authorization" },
})
);
expect(result.response).toEqual({ type: "unset" });
expect(result.session).toBe("some authorization");
});

it("returns 401 if handler doesn't set a session", async () => {
const result = await mw(createTestContext());
expect(result.response).toEqual({
type: "set",
body: { error: "Unauthorized" },
statusCode: 401,
headers: {},
});
});

it("merges request headers", async () => {
const context = createTestContext({
response: {
type: "partially-set",
headers: { "Some-Header": "some value" },
},
});

const result = await mw(context);

expect(result.response).toEqual({
type: "set",
body: { error: "Unauthorized" },
statusCode: 401,
headers: { "Some-Header": "some value" },
});
});

it("doesn't set a response if response is already set", async () => {
const context = createTestContext({
response: { type: "set", body: null, statusCode: 200, headers: {} },
});
const result = await mw(context);
expect(result.response).toBe(context.response);
});
});
14 changes: 10 additions & 4 deletions src/middlewares/authenticate/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import {
Middleware,
} from "../../createPipeline/helpers/types";
import { reply } from "./helpers/reply";
import { AuthenticateContextExtension } from "./helpers/types";
import {
AuthenticateCallback,
AuthenticateContextExtension,
} from "./helpers/types";

export const authenticate = <
Session,
In extends BasePipelineContext,
Out extends In & AuthenticateContextExtension
>(): Middleware<In, Out> => {
Out extends In & AuthenticateContextExtension<Session>
>(
cb: AuthenticateCallback<In, Session>
): Middleware<In, Out> => {
const middleware: Middleware<In, Out> = async (context) =>
reply(context) as Out;
reply<Session>(context, await cb(context)) as Out;

middleware.alwaysRun = true;

Expand Down
46 changes: 40 additions & 6 deletions src/middlewares/authenticate/helpers/reply.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,43 @@
import { BasePipelineContext } from "../../../createPipeline/helpers/types";
import { AuthenticateContext } from "./types";
import {
AuthenticateCallbackResult,
AuthenticateContext,
UnauthorizedResponseBody,
} from "./types";

export function reply(context: BasePipelineContext): AuthenticateContext {
return {
...context,
user: "stuffs",
} as AuthenticateContext;
export function reply<Session>(
context: BasePipelineContext,
result: AuthenticateCallbackResult<Session>
): AuthenticateContext<Session> {
if (context.response.type === "set") {
return {
...context,
session: null,
} as AuthenticateContext<Session>;
}

switch (result.type) {
case "set": {
return {
...context,
session: result.session,
} as AuthenticateContext<Session>;
}
case "unset": {
return {
...context,
session: null, // TODO not great
response: {
type: "set",
body: { error: "Unauthorized" } satisfies UnauthorizedResponseBody,
statusCode: 401,
headers: {
...(context.response.type !== "unset"
? context.response.headers
: {}),
},
},
} as AuthenticateContext<Session>;
}
}
}
21 changes: 16 additions & 5 deletions src/middlewares/authenticate/helpers/types.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import { BasePipelineContext } from "../../../createPipeline/helpers/types";

// TODO how to make this configurable?
export type UnauthorizedResponseBody = { error: "Unauthorized" };

type AuthenticateCustomMiddlewareResponse = {
authenticateCustomMiddlewareResponse: {
statusCode: 401;
body: { error: "Unauthorized" };
body: UnauthorizedResponseBody;
};
};

export type AuthenticateContextExtension = {
user: string;
export type AuthenticateContextExtension<Session> = {
session: Session;
} & AuthenticateCustomMiddlewareResponse;

export type AuthenticateContext = BasePipelineContext &
AuthenticateContextExtension;
export type AuthenticateContext<Session> = BasePipelineContext &
AuthenticateContextExtension<Session>;

export type AuthenticateCallbackResult<Session> =
| { type: "set"; session: Session }
| { type: "unset" };

export type AuthenticateCallback<In extends BasePipelineContext, Session> = (
context: In
) => Promise<AuthenticateCallbackResult<Session>>;
1 change: 1 addition & 0 deletions src/middlewares/deserialize/helpers/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function reply(
...(response
? {
response: {
// TODO why do we check for "unset" here? we don't expect "set" states, and so there's no need to mix in the entire response
...(context.response.type !== "unset" ? context.response : {}),
...response,
headers: {
Expand Down

0 comments on commit fbe241c

Please sign in to comment.