diff --git a/CHANGELOG.md b/CHANGELOG.md index c162469..cf24ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ ## Version 1 +### v1.2.0 + +- Ability to describe security schemas on the server and namespace level: + - These security schemas go directly to the generated documentation. + +```ts +// Single namespace +import { createSimpleConfig } from "zod-sockets"; + +const config = createSimpleConfig({ + security: [ + { + type: "httpApiKey", + description: "Server security schema", + in: "header", + name: "X-Api-Key", + }, + ], +}); +``` + +```ts +// Multiple namespaces +import { Config } from "zod-sockets"; + +const config = new Config({ + security: [ + { + type: "httpApiKey", + description: "Server security schema", + in: "header", + name: "X-Api-Key", + }, + ], +}).addNamespace({ + security: [ + { + type: "userPassword", + description: "Namespace security schema", + }, + ], +}); +``` + ### v1.1.0 - Supporting Node 22. diff --git a/README.md b/README.md index f0f675b..78d1fc6 100644 --- a/README.md +++ b/README.md @@ -613,3 +613,47 @@ const action = factory .example("input", ["example payload"]) .example("output", ["example acknowledgement"]); ``` + +### Adding security schemas to the documentation + +You can describe the security schemas for the generated documentation both on +server and namespace levels. + +```ts +// Single namespace +import { createSimpleConfig } from "zod-sockets"; + +const config = createSimpleConfig({ + security: [ + { + type: "httpApiKey", + description: "Server security schema", + in: "header", + name: "X-Api-Key", + }, + ], +}); +``` + +```ts +// Multiple namespaces +import { Config } from "zod-sockets"; + +const config = new Config({ + security: [ + { + type: "httpApiKey", + description: "Server security schema", + in: "header", + name: "X-Api-Key", + }, + ], +}).addNamespace({ + security: [ + { + type: "userPassword", + description: "Namespace security schema", + }, + ], +}); +``` diff --git a/example/example-documentation.yaml b/example/example-documentation.yaml index 2df6242..2bdcc5b 100644 --- a/example/example-documentation.yaml +++ b/example/example-documentation.yaml @@ -1,7 +1,7 @@ asyncapi: 3.0.0 info: title: Example APP - version: 1.1.0 + version: 1.2.0-beta1 contact: name: Anna Bocharova url: https://robintail.cz diff --git a/package.json b/package.json index 12bf0c1..addbca3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zod-sockets", - "version": "1.1.0", + "version": "1.2.0-beta1", "description": "Socket.IO solution with I/O validation and the ability to generate AsyncAPI specification and a contract for consumers", "type": "module", "main": "dist/index.cjs", diff --git a/src/__snapshots__/documentation.spec.ts.snap b/src/__snapshots__/documentation.spec.ts.snap index bec8af7..93ea21c 100644 --- a/src/__snapshots__/documentation.spec.ts.snap +++ b/src/__snapshots__/documentation.spec.ts.snap @@ -1979,3 +1979,88 @@ operations: - $ref: "#/channels/Root/messages/rootAckForIncomingTest" " `; + +exports[`Documentation > Security > should depict server and channel security 1`] = ` +"asyncapi: 3.0.0 +info: + title: Testing security + version: 3.4.5 +id: "urn:example:com:" +defaultContentType: text/plain +servers: + test: + host: example.com + pathname: / + protocol: https + security: + - $ref: "#/components/securitySchemes/serverSecurity0" +channels: + Root: + address: / + title: Namespace / + bindings: + ws: + bindingVersion: 0.1.0 + method: GET + headers: + type: object + properties: + connection: + type: string + const: Upgrade + upgrade: + type: string + const: websocket + query: + type: object + properties: + EIO: + type: string + const: "4" + description: The version of the protocol + transport: + type: string + enum: + - polling + - websocket + description: The name of the transport + sid: + type: string + description: The session identifier + required: + - EIO + - transport + externalDocs: + description: Engine.IO Protocol + url: https://socket.io/docs/v4/engine-io-protocol/ + messages: + rootIncomingTest: + name: test + title: test + payload: + type: array + additionalItems: false +components: + securitySchemes: + serverSecurity0: + type: httpApiKey + description: Sample security schema + in: header + name: X-Api-Key + rootSecurity0: + type: userPassword + description: Namespace level security sample +operations: + RootRecvOperationTest: + action: receive + channel: + $ref: "#/channels/Root" + messages: + - $ref: "#/channels/Root/messages/rootIncomingTest" + title: test + summary: Incoming event test + description: The message consumed by the application within the / namespace + security: + - $ref: "#/components/securitySchemes/rootSecurity0" +" +`; diff --git a/src/async-api/builder.ts b/src/async-api/builder.ts index addd37f..2ba4c1e 100644 --- a/src/async-api/builder.ts +++ b/src/async-api/builder.ts @@ -5,6 +5,7 @@ import { ServerObject, } from "./model"; import yaml from "yaml"; +import { SecuritySchemeObject } from "./security"; export class AsyncApiBuilder { protected readonly document: AsyncApiObject; @@ -39,6 +40,17 @@ export class AsyncApiBuilder { return this; } + public addSecurityScheme(name: string, scheme: SecuritySchemeObject): this { + this.document.components = { + ...this.document.components, + securitySchemes: { + ...this.document.components?.securitySchemes, + [name]: scheme, + }, + }; + return this; + } + getSpec(): AsyncApiObject { return this.document; } diff --git a/src/async-api/model.ts b/src/async-api/model.ts index 9fffe60..13c2197 100644 --- a/src/async-api/model.ts +++ b/src/async-api/model.ts @@ -1,3 +1,4 @@ +import { SecuritySchemeObject } from "./security"; import { WS } from "./websockets"; /** @@ -18,7 +19,7 @@ export interface ServerObject { title?: string; description?: string; variables?: Record; - security?: SecuritySchemeObject[]; + security?: Array; tags?: TagObject[]; externalDocs?: ExternalDocumentationObject; bindings?: Bindings; @@ -264,7 +265,7 @@ export interface OperationTraitObject { title?: string; summary?: string; description?: string; - security?: SecuritySchemeObject[]; + security?: Array; tags?: TagObject[]; externalDocs?: ExternalDocumentationObject; bindings?: Bindings; @@ -324,48 +325,6 @@ export interface TagObject { externalDocs?: ExternalDocumentationObject; } -export interface SecuritySchemeObject { - type: - | "userPassword" - | "apiKey" - | "X509" - | "symmetricEncryption" - | "asymmetricEncryption" - | "http" - | "oauth2" - | "openIdConnect"; - description?: string; - /** @desc for httpApiKey */ - name?: string; - /** @desc Valid values are "user" and "password" for apiKey and "query", "header" or "cookie" for httpApiKey. */ - in?: "user" | "password" | "query" | "header" | "cookie"; - /** @desc for http */ - scheme?: string; - /** @desc for http */ - bearerFormat?: string; - /** @desc for oauth2 */ - flows?: OAuthFlowsObject; - /** @desc for openIdConnect */ - openIdConnectUrl?: string; -} - -export interface OAuthFlowsObject { - implicit?: OAuthFlowObject; - password?: OAuthFlowObject; - clientCredentials?: OAuthFlowObject; - authorizationCode?: OAuthFlowObject; -} - -export interface OAuthFlowObject { - /** @desc for implicit and authorizationCode */ - authorizationUrl?: string; - /** @desc for password, clientCredentials and authorizationCode */ - tokenUrl?: string; - refreshUrl?: string; - /** @desc A map between the scope name and a short description for it. */ - scopes: Record; -} - /** @since 3.0.0 partially extends SchemaObject; schema prop removed */ export interface ParameterObject extends Pick { diff --git a/src/async-api/security.ts b/src/async-api/security.ts new file mode 100644 index 0000000..7bb04b3 --- /dev/null +++ b/src/async-api/security.ts @@ -0,0 +1,82 @@ +interface FlowCommons { + /** @desc The URL to be used for obtaining refresh tokens. */ + refreshUrl?: string; + /** @desc A map between the scope name and a short description for it. */ + availableScopes: Record; +} + +interface AuthHavingFlow { + /** @desc The authorization URL to be used for this flow. */ + authorizationUrl: string; +} + +interface TokenHavingFlow { + /** @desc The token URL to be used for this flow. */ + tokenUrl: string; +} + +export interface OAuthFlowsObject { + implicit?: FlowCommons & AuthHavingFlow; + password?: FlowCommons & TokenHavingFlow; + clientCredentials?: FlowCommons & TokenHavingFlow; + authorizationCode?: FlowCommons & AuthHavingFlow & TokenHavingFlow; +} + +interface HttpApiKeySecurity { + type: "httpApiKey"; + /** @desc The name of the header, query or cookie parameter to be used. */ + name: string; + in: "query" | "header" | "cookie"; +} + +interface ApiKeySecurity { + type: "apiKey"; + in: "user" | "password"; +} + +interface HttpSecurity { + type: "http"; + /** @link https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml */ + scheme?: string; + /** @example "Bearer" */ + bearerFormat?: string; +} + +interface ScopesHavingSecurity { + /** @desc List of the needed scope names. An empty array means no scopes are needed. */ + scopes?: string[]; +} + +interface OAuth2Security extends ScopesHavingSecurity { + type: "oauth2"; + flows: OAuthFlowsObject; +} + +interface OpenIdConnectSecurity extends ScopesHavingSecurity { + type: "openIdConnect"; + /** @desc OpenId Connect URL to discover OAuth2 configuration values */ + openIdConnectUrl: string; +} + +interface OtherSecurity { + type: + | "userPassword" + | "X509" + | "symmetricEncryption" + | "asymmetricEncryption" + | "plain" + | "scramSha256" + | "scramSha512" + | "gssapi"; +} + +export type SecuritySchemeObject = { + description?: string; +} & ( + | HttpApiKeySecurity + | ApiKeySecurity + | HttpSecurity + | OAuth2Security + | OpenIdConnectSecurity + | OtherSecurity +); diff --git a/src/config.spec.ts b/src/config.spec.ts index 844fedc..b0a5de6 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -19,12 +19,14 @@ describe("Config", () => { hooks: {}, metadata: z.object({}), examples: {}, + security: [], }, test: { emission: {}, hooks: {}, metadata: z.object({}), examples: {}, + security: [], }, }, timeout: 3000, @@ -37,12 +39,14 @@ describe("Config", () => { hooks: {}, metadata: expect.any(z.ZodObject), examples: {}, + security: [], }, test: { emission: {}, hooks: {}, metadata: expect.any(z.ZodObject), examples: {}, + security: [], }, }); }); @@ -66,6 +70,7 @@ describe("Config", () => { hooks: {}, metadata: expect.any(z.ZodObject), examples: {}, + security: [], }, }); }); @@ -82,6 +87,7 @@ describe("Config", () => { hooks: {}, metadata: expect.any(z.ZodObject), examples: {}, + security: [], }, }); }); @@ -101,6 +107,7 @@ describe("Config", () => { hooks: {}, metadata: expect.any(z.ZodObject), examples: {}, + security: [], }, }); }); diff --git a/src/config.ts b/src/config.ts index dda2d42..4730c28 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { SecuritySchemeObject } from "./async-api/security"; import { EmissionMap } from "./emission"; import { Namespace, Namespaces, RootNS, rootNS } from "./namespace"; @@ -19,25 +20,29 @@ interface ConstructorOptions { * @see Namespace * */ namespaces?: NS; + security?: SecuritySchemeObject[]; } /** @todo consider using it for namespaces declaration only */ export class Config { public readonly timeout: number; public readonly startupLogo: boolean; + public readonly security: SecuritySchemeObject[]; public readonly namespaces: T; public constructor({ timeout = 2000, startupLogo = true, namespaces = {} as T, + security = [], }: ConstructorOptions = {}) { this.timeout = timeout; this.startupLogo = startupLogo; this.namespaces = namespaces; + this.security = security; } - /** @default { path: "/", emission: {}, metadata: z.object({}), hooks: {}, examples: {} } */ + /** @default { path: "/", emission: {}, metadata: z.object({}), hooks: {}, examples: {}, security: [] } */ public addNamespace< E extends EmissionMap = {}, D extends z.SomeZodObject = z.ZodObject<{}>, @@ -48,11 +53,18 @@ export class Config { metadata = z.object({}) as D, hooks = {}, examples = {}, + security = [], }: Partial> & { path?: K }): Config< Omit & Record> > { const { namespaces, ...rest } = this; - const ns: Namespace = { emission, examples, hooks, metadata }; + const ns: Namespace = { + emission, + examples, + hooks, + metadata, + security, + }; return new Config({ ...rest, namespaces: { ...namespaces, [path]: ns } }); } } @@ -64,13 +76,14 @@ export const createSimpleConfig = < >({ startupLogo, timeout, + security, emission, examples, hooks, metadata, }: Omit, "namespaces"> & Partial> = {}) => - new Config({ startupLogo, timeout }).addNamespace({ + new Config({ startupLogo, timeout, security }).addNamespace({ emission, examples, metadata, diff --git a/src/documentation-helpers.ts b/src/documentation-helpers.ts index 00170cf..2b47f0c 100644 --- a/src/documentation-helpers.ts +++ b/src/documentation-helpers.ts @@ -569,12 +569,14 @@ export const depictOperation = ({ ackId, event, ns, + securityIds, }: { channelId: string; messageId: string; ackId?: string; event: string; ns: string; + securityIds?: string[]; } & AsyncAPIContext): OperationObject => ({ action: direction === "out" ? "send" : "receive", channel: { $ref: `#/channels/${channelId}` }, @@ -584,6 +586,12 @@ export const depictOperation = ({ description: `The message ${direction === "out" ? "produced" : "consumed"} by ` + `the application within the ${ns} namespace`, + security: + securityIds && securityIds.length + ? securityIds.map((id) => ({ + $ref: `#/components/securitySchemes/${id}`, + })) + : undefined, reply: ackId ? { address: { diff --git a/src/documentation.spec.ts b/src/documentation.spec.ts index 1ffdbc6..38e4465 100644 --- a/src/documentation.spec.ts +++ b/src/documentation.spec.ts @@ -2,7 +2,7 @@ import { SchemaObject } from "./async-api/model"; import { config as exampleConfig } from "../example/config"; import { actions } from "../example/actions"; import { ActionsFactory } from "./actions-factory"; -import { createSimpleConfig } from "./config"; +import { Config, createSimpleConfig } from "./config"; import { Documentation } from "./documentation"; import { z } from "zod"; import { describe, expect, test, vi } from "vitest"; @@ -444,4 +444,42 @@ describe("Documentation", () => { ).toThrow(`Zod type ${zodType._def.typeName} is unsupported.`); }); }); + + describe("Security", () => { + const secureConfig = new Config({ + security: [ + { + type: "httpApiKey", + description: "Sample security schema", + in: "header", + name: "X-Api-Key", + }, + ], + }).addNamespace({ + security: [ + { + type: "userPassword", + description: "Namespace level security sample", + }, + ], + }); + const secureFactory = new ActionsFactory(secureConfig); + + test("should depict server and channel security", () => { + const spec = new Documentation({ + config: secureConfig, + servers: { test: { url: "https://example.com" } }, + actions: [ + secureFactory.build({ + event: "test", + input: z.tuple([]), + handler: async () => {}, + }), + ], + version: "3.4.5", + title: "Testing security", + }).getSpecAsYaml(); + expect(spec).toMatchSnapshot(); + }); + }); }); diff --git a/src/documentation.ts b/src/documentation.ts index 2e1c54e..22e03b0 100644 --- a/src/documentation.ts +++ b/src/documentation.ts @@ -77,7 +77,7 @@ export class Documentation extends AsyncApiBuilder { public constructor({ actions, - config: { namespaces }, + config: { namespaces, security: globalSecurity }, title, version, documentId, @@ -91,6 +91,12 @@ export class Documentation extends AsyncApiBuilder { id: documentId, defaultContentType: "text/plain", }); + const globalSecurityIds: string[] = []; + for (const [index, schema] of Object.entries(globalSecurity)) { + const id = lcFirst(makeCleanId(`server security ${index}`)); + this.addSecurityScheme(id, schema); + globalSecurityIds.push(id); + } for (const server in servers) { const uri = new URL(servers[server].url); this.addServer(server, { @@ -98,16 +104,29 @@ export class Documentation extends AsyncApiBuilder { host: uri.host, pathname: uri.pathname, protocol: uri.protocol.slice(0, -1), + security: globalSecurityIds.length + ? globalSecurityIds.map((id) => ({ + $ref: `#/components/securitySchemes/${id}`, + })) + : undefined, }); if (!this.document.id) { this.document.id = `urn:${uri.host.split(".").concat(uri.pathname.slice(1).split("/")).join(":")}`; } } const channelBinding = this.#makeChannelBinding(); - for (const [dirty, { emission, examples }] of Object.entries(namespaces)) { + for (const [dirty, { emission, examples, security }] of Object.entries( + namespaces, + )) { const ns = normalizeNS(dirty); const channelId = makeCleanId(ns) || "Root"; const messages: MessagesObject = {}; + const securityIds: string[] = []; + for (const [index, schema] of Object.entries(security)) { + const id = lcFirst(makeCleanId(`${channelId} security ${index}`)); + this.addSecurityScheme(id, schema); + securityIds.push(id); + } for (const [event, { schema, ack }] of Object.entries(emission)) { const messageId = lcFirst( makeCleanId(`${channelId} outgoing ${event}`), @@ -176,6 +195,7 @@ export class Documentation extends AsyncApiBuilder { event, ns, ackId: output && ackId, + securityIds: securityIds, }), ); } diff --git a/src/index.spec.ts b/src/index.spec.ts index 834ac88..51ac2e3 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -35,6 +35,7 @@ describe("Entrypoint", () => { emission: { event: { schema: z.tuple([]) } }, hooks: {}, examples: {}, + security: [], metadata: z.object({ count: z.number() }), }); expectType< diff --git a/src/namespace.ts b/src/namespace.ts index 21e53c4..0168923 100644 --- a/src/namespace.ts +++ b/src/namespace.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { SecuritySchemeObject } from "./async-api/security"; import { Emission, EmissionMap } from "./emission"; import { Hooks } from "./hooks"; @@ -26,6 +27,7 @@ export interface Namespace { hooks: Partial>; /** @desc Schema of the client metadata in this namespace */ metadata: D; + security: SecuritySchemeObject[]; } export type Namespaces = Record<