Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Security schemas for the Middlewares #523

Merged
merged 25 commits into from
Aug 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9a6422f
Sample security prop for the Middleware.
RobinTail Jul 24, 2022
8f987d8
Extracting Security to a separate file.
RobinTail Jul 24, 2022
d53c09a
Introducing Endpoint::getSecurity().
RobinTail Jul 24, 2022
61699df
Introducing the sample depictSecurity() helper implementation.
RobinTail Jul 24, 2022
ab74350
Sample implementation for attaching security schemas.
RobinTail Jul 24, 2022
056e60a
Testing on the example.
RobinTail Jul 24, 2022
2e05215
Adding input security schema.
RobinTail Jul 24, 2022
bd4120d
Restricting the InputSecurity schema.
RobinTail Jul 24, 2022
b9c8f32
Ref: getSecurity(): filter out undefined first.
RobinTail Jul 29, 2022
75ad101
Introducing the LogicalContainer for describing the Security.
RobinTail Aug 1, 2022
da0bf93
Ref: removing redundant type declaration in LogicalContainer.
RobinTail Aug 1, 2022
377a96e
Merge branch 'master' into auth-schemas
RobinTail Aug 6, 2022
f5bac63
Limited LogicalContainer with keeping it upto 2 levels in Endpoint::g…
RobinTail Aug 6, 2022
8d36114
Testing mapLogicalContainer().
RobinTail Aug 6, 2022
9dcacc1
Testing andToOr() helper.
RobinTail Aug 6, 2022
2b8d931
Testing combineContainers().
RobinTail Aug 6, 2022
29a9a92
Ref: extracting flattenAnds().
RobinTail Aug 6, 2022
0b08863
Testing depictSecurity().
RobinTail Aug 6, 2022
e990b93
Fix and test depictSecurityNames().
RobinTail Aug 6, 2022
649fa97
Minor: jsdoc.
RobinTail Aug 6, 2022
4cade10
Fix optional bearer format depiction.
RobinTail Aug 6, 2022
46cd2ac
Minor: jsdoc.
RobinTail Aug 6, 2022
e937163
Changelog: future version 7.7.0.
RobinTail Aug 6, 2022
e6c548a
Fix: deduplicating the security schema names in OpenAPI generator.
RobinTail Aug 7, 2022
d780fa7
Mentioning the feature in Readme.
RobinTail Aug 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,64 @@

## Version 7

### v7.7.0

- Feature #523: Ability to specify Security schemas of your Middlewares and depict the Authentication of your API.
- OpenAPI generator now can depict the [Authentication](https://swagger.io/docs/specification/authentication/) of your
endpoints as a part of the generated documentation.
- There is a new optional property `security` of `createMiddleware()`.
- You can specify a single or several security schemas in that property.
- For several security schemas `security` support a new `LogicalContainer` that can contain upto 2 nested levels.
- Supported security types: `basic`, `bearer`, `input`, `header`, `cookie`, `openid` and `oauth2`.
- OpenID and OAuth2 security types are currently have the limited support: without scopes.

```typescript
// example middleware
import { createMiddleware } from "express-zod-api";

const authMiddleware = createMiddleware({
security: {
// requires the "key" in inputs and a custom "token" headers
and: [
{ type: "input", name: "key" },
{ type: "header", name: "token" },
],
},
input: z.object({
key: z.string().min(1),
}),
middleware: async ({ input: { key }, request }) => {
if (key !== "123") {
throw createHttpError(401, "Invalid key");
}
if (request.headers.token !== "456") {
throw createHttpError(401, "Invalid token");
}
return { token: request.headers.token };
},
});

// another example with logical OR
createMiddleware({
security: {
// requires either input and header OR bearer header
or: [
{
and: [
{ type: "input", name: "key" },
{ type: "header", name: "token" },
],
},
{
type: "bearer",
format: "JWT",
},
],
},
//...
});
```

### v7.6.3

- [@rayzr522](https://github.com/rayzr522) has fixed the resolution of types in the ESM build for the `nodenext` case.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,13 @@ Here is an example of the authentication middleware, that checks a `key` from in
import { createMiddleware, createHttpError, z } from "express-zod-api";

const authMiddleware = createMiddleware({
security: {
// this information is optional and used for the generated documentation (OpenAPI)
and: [
{ type: "input", name: "key" },
{ type: "header", name: "token" },
],
},
input: z.object({
key: z.string().min(1),
}),
Expand Down
13 changes: 12 additions & 1 deletion example/example.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ paths:
key: 1234-5678-90
name: John Doe
birthday: 1963-04-21
security:
- APIKEY_1: []
APIKEY_2: []
/v1/avatar/send:
get:
responses:
Expand Down Expand Up @@ -326,7 +329,15 @@ components:
examples: {}
requestBodies: {}
headers: {}
securitySchemes: {}
securitySchemes:
APIKEY_1:
type: apiKey
in: query
name: key
APIKEY_2:
type: apiKey
in: header
name: token
links: {}
callbacks: {}
tags: []
Expand Down
6 changes: 6 additions & 0 deletions example/middlewares.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { createMiddleware, Method, createHttpError, z, withMeta } from "../src";

export const authMiddleware = createMiddleware({
security: {
and: [
{ type: "input", name: "key" },
{ type: "header", name: "token" },
],
},
input: withMeta(
z.object({
key: z.string().min(1),
Expand Down
11 changes: 11 additions & 0 deletions src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
getInitialInput,
IOSchema,
} from "./common-helpers";
import { combineContainers, LogicalContainer } from "./logical-container";
import { AuxMethod, Method, MethodsDefinition } from "./method";
import { AnyMiddlewareDef } from "./middleware";
import { lastResortHandler, ResultHandlerDefinition } from "./result-handler";
import { Security } from "./security";

export type Handler<IN, OUT, OPT> = (params: {
input: IN;
Expand All @@ -36,6 +38,7 @@ export abstract class AbstractEndpoint {
public abstract getInputMimeTypes(): string[];
public abstract getPositiveMimeTypes(): string[];
public abstract getNegativeMimeTypes(): string[];
public abstract getSecurity(): LogicalContainer<Security>;
}

type EndpointProps<
Expand Down Expand Up @@ -133,6 +136,14 @@ export class Endpoint<
return this.resultHandler.getNegativeResponse().mimeTypes;
}

public override getSecurity(): LogicalContainer<Security> {
return this.middlewares.reduce<LogicalContainer<Security>>(
(acc, middleware) =>
middleware.security ? combineContainers(acc, middleware.security) : acc,
{ and: [] }
);
}

#getDefaultCorsHeaders(): Record<string, string> {
const accessMethods = (this.methods as (M | AuxMethod)[])
.concat("options")
Expand Down
103 changes: 103 additions & 0 deletions src/logical-container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { combinations } from "./common-helpers";

type LogicalOr<T> = { or: T[] };
type LogicalAnd<T> = { and: T[] };

export type LogicalContainer<T> =
| LogicalOr<T | LogicalAnd<T>>
| LogicalAnd<T | LogicalOr<T>>
| T;

/** @desc combines several LogicalAnds into a one */
export const flattenAnds = <T>(
subject: (T | LogicalAnd<T>)[]
): LogicalAnd<T> => ({
and: subject.reduce<T[]>(
(agg, item) =>
agg.concat(typeof item === "object" && "and" in item ? item.and : item),
[]
),
});

/** @desc creates a LogicalContainer out of another one */
export const mapLogicalContainer = <T, S>(
container: LogicalContainer<T>,
fn: (subject: T) => S
): LogicalContainer<S> => {
if (typeof container === "object") {
if ("and" in container) {
return {
and: container.and.map((entry) =>
typeof entry === "object" && "or" in entry
? { or: entry.or.map(fn) }
: fn(entry)
),
};
}
if ("or" in container) {
return {
or: container.or.map((entry) =>
typeof entry === "object" && "and" in entry
? { and: entry.and.map(fn) }
: fn(entry)
),
};
}
}
return fn(container);
};

/** @desc converts LogicalAnd into LogicalOr */
export const andToOr = <T>(
subject: LogicalAnd<T | LogicalOr<T>>
): LogicalOr<T | LogicalAnd<T>> => {
return subject.and.reduce<LogicalOr<T | LogicalAnd<T>>>(
(acc, item) => {
const combs = combinations(
acc.or,
typeof item === "object" && "or" in item ? item.or : [item]
);
if (combs.type === "single") {
acc.or.push(...combs.value);
} else {
acc.or = combs.value.map(flattenAnds);
}
return acc;
},
{
or: [],
}
);
};

/** @desc reducer, combines two LogicalContainers */
export const combineContainers = <T>(
a: LogicalContainer<T>,
b: LogicalContainer<T>
): LogicalContainer<T> => {
if (typeof a === "object" && typeof b === "object") {
if ("and" in a) {
if ("and" in b) {
return flattenAnds([a, b]);
}
if ("or" in b) {
return combineContainers(andToOr(a), b);
}
}
if ("or" in a) {
if ("and" in b) {
return combineContainers(b, a);
}
if ("or" in b) {
const combs = combinations(a.or, b.or);
return {
or:
combs.type === "single"
? combs.value
: combs.value.map(flattenAnds),
};
}
}
}
return { and: [a as T, b as T] };
};
3 changes: 3 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { HttpError } from "http-errors";
import { Logger } from "winston";
import { z } from "zod";
import { FlatObject, IOSchema } from "./common-helpers";
import { LogicalContainer } from "./logical-container";
import { Security } from "./security";

interface MiddlewareParams<IN, OPT> {
input: IN;
Expand All @@ -22,6 +24,7 @@ export interface MiddlewareCreationProps<
OUT extends FlatObject
> {
input: IN;
security?: LogicalContainer<Security<keyof z.input<IN> & string>>;
middleware: Middleware<z.output<IN>, OPT, OUT>;
}

Expand Down
93 changes: 93 additions & 0 deletions src/open-api-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
import {
RequestBodyObject,
ResponseObject,
SecurityRequirementObject,
SecuritySchemeObject,
} from "openapi3-ts/src/model/OpenApi";
import { omit } from "ramda";
import { z } from "zod";
Expand All @@ -25,8 +27,14 @@ import { ZodDateOut, ZodDateOutDef } from "./date-out-schema";
import { AbstractEndpoint } from "./endpoint";
import { OpenAPIError } from "./errors";
import { ZodFile, ZodFileDef } from "./file-schema";
import {
andToOr,
LogicalContainer,
mapLogicalContainer,
} from "./logical-container";
import { copyMeta } from "./metadata";
import { Method } from "./method";
import { Security } from "./security";
import { ZodUpload, ZodUploadDef } from "./upload-schema";

type MediaExamples = Pick<MediaTypeObject, "examples">;
Expand Down Expand Up @@ -714,6 +722,91 @@ export const depictResponse = ({
};
};

type SecurityHelper<K extends Security["type"]> = (
security: Security & { type: K }
) => SecuritySchemeObject;

const depictBasicSecurity: SecurityHelper<"basic"> = ({}) => ({
type: "http",
scheme: "basic",
});
const depictBearerSecurity: SecurityHelper<"bearer"> = ({
format: bearerFormat,
}) => ({
type: "http",
scheme: "bearer",
...(bearerFormat ? { bearerFormat } : {}),
});
// @todo add description on actual input placement
const depictInputSecurity: SecurityHelper<"input"> = ({ name }) => ({
type: "apiKey",
in: "query", // body is not supported yet, https://swagger.io/docs/specification/authentication/api-keys/
name,
});
const depictHeaderSecurity: SecurityHelper<"header"> = ({ name }) => ({
type: "apiKey",
in: "header",
name,
});
const depictCookieSecurity: SecurityHelper<"cookie"> = ({ name }) => ({
type: "apiKey",
in: "cookie",
name,
});
// @todo implement scopes
const depictOpenIdSecurity: SecurityHelper<"openid"> = ({
url: openIdConnectUrl,
}) => ({
type: "openIdConnect",
openIdConnectUrl,
});
// @todo implement scopes
const depictOAuth2Security: SecurityHelper<"oauth2"> = ({}) => ({
type: "oauth2",
});

export const depictSecurity = (
container: LogicalContainer<Security>
): LogicalContainer<SecuritySchemeObject> => {
const methods: { [K in Security["type"]]: SecurityHelper<K> } = {
basic: depictBasicSecurity,
bearer: depictBearerSecurity,
input: depictInputSecurity,
header: depictHeaderSecurity,
cookie: depictCookieSecurity,
openid: depictOpenIdSecurity,
oauth2: depictOAuth2Security,
};
return mapLogicalContainer(container, (security) =>
(methods[security.type] as SecurityHelper<typeof security.type>)(security)
);
};

export const depictSecurityNames = (
container: LogicalContainer<string>
): SecurityRequirementObject[] => {
if (typeof container === "object") {
if ("or" in container) {
return container.or.map((entry) =>
(typeof entry === "string"
? [entry]
: entry.and
).reduce<SecurityRequirementObject>(
(agg, name) => ({
...agg,
[name]: [],
}),
{}
)
);
}
if ("and" in container) {
return depictSecurityNames(andToOr(container));
}
}
return depictSecurityNames({ or: [container] });
};

export const depictRequest = ({
method,
path,
Expand Down
Loading