@darthcav/ts-http-server 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +58 -8
  2. package/dist/__tests__/defaultPlugins.test.js +47 -2
  3. package/dist/__tests__/defaultPlugins.test.js.map +1 -1
  4. package/dist/__tests__/defaultRoutes.test.js +226 -1
  5. package/dist/__tests__/defaultRoutes.test.js.map +1 -1
  6. package/dist/__tests__/keycloak.test.d.ts +2 -0
  7. package/dist/__tests__/keycloak.test.d.ts.map +1 -0
  8. package/dist/__tests__/keycloak.test.js +105 -0
  9. package/dist/__tests__/keycloak.test.js.map +1 -0
  10. package/dist/__tests__/launcher.test.js +34 -0
  11. package/dist/__tests__/launcher.test.js.map +1 -1
  12. package/dist/auth/keycloak.d.ts +17 -0
  13. package/dist/auth/keycloak.d.ts.map +1 -0
  14. package/dist/auth/keycloak.js +35 -0
  15. package/dist/auth/keycloak.js.map +1 -0
  16. package/dist/defaults/defaultPlugins.d.ts +12 -7
  17. package/dist/defaults/defaultPlugins.d.ts.map +1 -1
  18. package/dist/defaults/defaultPlugins.js +109 -2
  19. package/dist/defaults/defaultPlugins.js.map +1 -1
  20. package/dist/defaults/defaultRoutes.d.ts +5 -0
  21. package/dist/defaults/defaultRoutes.d.ts.map +1 -1
  22. package/dist/defaults/defaultRoutes.js +30 -0
  23. package/dist/defaults/defaultRoutes.js.map +1 -1
  24. package/dist/hooks/authPreHandler.d.ts +19 -0
  25. package/dist/hooks/authPreHandler.d.ts.map +1 -0
  26. package/dist/hooks/authPreHandler.js +32 -0
  27. package/dist/hooks/authPreHandler.js.map +1 -0
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/launcher.d.ts +2 -2
  33. package/dist/launcher.d.ts.map +1 -1
  34. package/dist/launcher.js +7 -2
  35. package/dist/launcher.js.map +1 -1
  36. package/dist/start.js +34 -5
  37. package/dist/start.js.map +1 -1
  38. package/dist/types.d.ts +64 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +10 -3
  41. package/src/auth/keycloak.ts +43 -0
  42. package/src/defaults/defaultPlugins.ts +141 -7
  43. package/src/defaults/defaultRoutes.ts +30 -1
  44. package/src/hooks/authPreHandler.ts +41 -0
  45. package/src/index.ts +7 -0
  46. package/src/launcher.ts +11 -1
  47. package/src/openapi/api.yaml +150 -0
  48. package/src/openapi/schemas/Error.yaml +14 -0
  49. package/src/start.ts +44 -5
  50. package/src/types.ts +71 -2
  51. package/src/views/index.ejs +4 -0
package/dist/types.d.ts CHANGED
@@ -10,17 +10,50 @@ export type FSTPlugin = {
10
10
  /** Optional options forwarded to the plugin on registration. */
11
11
  opts?: FastifyPluginOptions;
12
12
  };
13
+ /**
14
+ * Configuration for Keycloak-backed JWT authentication.
15
+ */
16
+ export type KeycloakAuthConfig = {
17
+ /** Keycloak server base URL, e.g. `https://auth.example.com`. */
18
+ url: string;
19
+ /** Keycloak realm name. */
20
+ realm: string;
21
+ /** Client ID registered in the realm; used as the expected audience. */
22
+ clientId: string;
23
+ /** Client secret for the registered client. */
24
+ clientSecret: string;
25
+ };
26
+ /**
27
+ * Async function that verifies a bearer token from the `Authorization` header.
28
+ *
29
+ * Return `true` to allow the request, `false` to reject it with the default
30
+ * 401 response, or throw to surface a custom error.
31
+ */
32
+ export type TokenVerifier = (authorizationHeader: string | undefined) => Promise<boolean>;
13
33
  /**
14
34
  * Application locals decorated onto the Fastify instance and available
15
35
  * throughout the request lifecycle.
16
36
  */
17
37
  export type LauncherLocals = {
18
38
  /** Package metadata (e.g. contents of `package.json`). */
19
- pkg?: object;
39
+ pkg?: Record<string, unknown>;
20
40
  /** Hostname the server will bind to. */
21
41
  host?: string;
22
42
  /** Port the server will listen on. */
23
43
  port?: number;
44
+ /**
45
+ * Glob patterns (picomatch) for routes that require bearer-token
46
+ * authentication via the `verifyToken` Fastify decorator.
47
+ * When `undefined` or empty, authentication is disabled.
48
+ *
49
+ * Example: `["/api/**"]`
50
+ */
51
+ authPaths?: string[];
52
+ /**
53
+ * Protection-space label used in the `WWW-Authenticate` challenge (RFC 6750).
54
+ * Typically the Keycloak realm name. Defaults to `"api"` when omitted.
55
+ */
56
+ authRealm?: string;
24
57
  /** Any additional application-specific locals. */
25
58
  [key: string]: unknown;
26
59
  };
@@ -38,9 +71,39 @@ export type LauncherOptions = {
38
71
  routes: Map<string, RouteOptions>;
39
72
  /** Map of named decorators to add to the Fastify instance. */
40
73
  decorators?: Map<string, unknown>;
74
+ /**
75
+ * Token verifier registered as the `verifyToken` Fastify decorator.
76
+ *
77
+ * When omitted and `locals.authPaths` is set, all protected routes will
78
+ * respond with `401 Unauthorized`.
79
+ */
80
+ verifyToken?: TokenVerifier;
41
81
  /** Optional Fastify server options (merged over {@link defaultFastifyOptions}). */
42
82
  opts?: FastifyServerOptions;
43
83
  /** Optional callback invoked once the server is listening. */
44
84
  done?: () => void;
45
85
  };
86
+ /**
87
+ * Options accepted by the {@link defaultPlugins} function.
88
+ */
89
+ export type DefaultPluginsOptions = {
90
+ /** Application locals; `locals.pkg` is exposed as the default EJS context. */
91
+ locals: LauncherLocals;
92
+ /** Optional base directory for resolving the `src/` folder; defaults to the parent of `import.meta.dirname`. */
93
+ baseDir?: string | null;
94
+ /** Optional Keycloak configuration used to mark the generated `/api/` OpenAPI operations as OpenID Connect–protected. */
95
+ keycloakAuth?: KeycloakAuthConfig;
96
+ };
97
+ /**
98
+ * Fastify module augmentation that exposes {@link LauncherLocals} and the
99
+ * {@link TokenVerifier} as first-class decorators on every `FastifyInstance`.
100
+ *
101
+ * Both are registered in {@link launcher} via `fastify.decorate(...)`.
102
+ */
103
+ declare module "fastify" {
104
+ interface FastifyInstance {
105
+ locals: LauncherLocals;
106
+ verifyToken: TokenVerifier;
107
+ }
108
+ }
46
109
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EACR,kBAAkB,EAClB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,YAAY,EACf,MAAM,SAAS,CAAA;AAEhB;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG;IACpB,+CAA+C;IAE/C,MAAM,EAAE,qBAAqB,CAAC,GAAG,CAAC,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAA;IAC5D,gEAAgE;IAChE,IAAI,CAAC,EAAE,oBAAoB,CAAA;CAC9B,CAAA;AAED;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG;IACzB,0DAA0D;IAC1D,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,kDAAkD;IAClD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACzB,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC1B,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,MAAM,EAAE,cAAc,CAAA;IACtB,wCAAwC;IACxC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IAC/B,uCAAuC;IACvC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;IACjC,8DAA8D;IAC9D,UAAU,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,mFAAmF;IACnF,IAAI,CAAC,EAAE,oBAAoB,CAAA;IAC3B,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EACR,kBAAkB,EAClB,qBAAqB,EACrB,oBAAoB,EACpB,oBAAoB,EACpB,YAAY,EACf,MAAM,SAAS,CAAA;AAEhB;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG;IACpB,+CAA+C;IAE/C,MAAM,EAAE,qBAAqB,CAAC,GAAG,CAAC,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAA;IAC5D,gEAAgE;IAChE,IAAI,CAAC,EAAE,oBAAoB,CAAA;CAC9B,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG;IAC7B,iEAAiE;IACjE,GAAG,EAAE,MAAM,CAAA;IACX,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAA;IAChB,+CAA+C;IAC/C,YAAY,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,CACxB,mBAAmB,EAAE,MAAM,GAAG,SAAS,KACtC,OAAO,CAAC,OAAO,CAAC,CAAA;AAErB;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG;IACzB,0DAA0D;IAC1D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7B,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;IACpB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kDAAkD;IAClD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACzB,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC1B,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAA;IACd,8DAA8D;IAC9D,MAAM,EAAE,cAAc,CAAA;IACtB,wCAAwC;IACxC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IAC/B,uCAAuC;IACvC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;IACjC,8DAA8D;IAC9D,UAAU,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,aAAa,CAAA;IAC3B,mFAAmF;IACnF,IAAI,CAAC,EAAE,oBAAoB,CAAA;IAC3B,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAChC,8EAA8E;IAC9E,MAAM,EAAE,cAAc,CAAA;IACtB,gHAAgH;IAChH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,yHAAyH;IACzH,YAAY,CAAC,EAAE,kBAAkB,CAAA;CACpC,CAAA;AAED;;;;;GAKG;AACH,OAAO,QAAQ,SAAS,CAAC;IACrB,UAAU,eAAe;QACrB,MAAM,EAAE,cAAc,CAAA;QACtB,WAAW,EAAE,aAAa,CAAA;KAC7B;CACJ"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@darthcav/ts-http-server",
3
3
  "description": "A TypeScript HTTP server for Node.js >= 25",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "author": {
6
6
  "name": "darthcav",
7
7
  "url": "https://github.com/darthcav"
@@ -30,18 +30,25 @@
30
30
  "@fastify/etag": "6.1.0",
31
31
  "@fastify/helmet": "13.0.2",
32
32
  "@fastify/static": "9.0.0",
33
+ "@fastify/swagger": "9.7.0",
34
+ "@fastify/swagger-ui": "5.2.5",
33
35
  "@fastify/view": "11.1.1",
34
36
  "@hapi/boom": "10.0.1",
35
37
  "@logtape/fastify": "2.0.5",
36
38
  "@logtape/logtape": "2.0.5",
37
39
  "ejs": "5.0.1",
38
- "fastify": "5.8.4"
40
+ "fastify": "5.8.4",
41
+ "jose": "6.2.2",
42
+ "picomatch": "4.0.4",
43
+ "yaml": "2.8.3"
39
44
  },
40
45
  "devDependencies": {
41
- "@biomejs/biome": "2.4.9",
46
+ "@biomejs/biome": "2.4.10",
42
47
  "@types/ejs": "3.1.5",
43
48
  "@types/node": "25.5.0",
49
+ "@types/picomatch": "4.0.2",
44
50
  "asserttt": "1.0.1",
51
+ "openapi-types": "12.1.3",
45
52
  "prettier": "3.8.1",
46
53
  "shx": "0.4.0",
47
54
  "typedoc": "0.28.18"
@@ -0,0 +1,43 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose"
2
+ import type { KeycloakAuthConfig, TokenVerifier } from "../types.ts"
3
+
4
+ /**
5
+ * Creates a JWT token verifier backed by a Keycloak realm's JWKS endpoint.
6
+ *
7
+ * The JWKS keys are fetched lazily on the first verification request and
8
+ * cached; key rotation is handled automatically by `jose`.
9
+ *
10
+ * The verifier extracts the bearer token from the `Authorization` header,
11
+ * verifies the JWT signature against the realm's public keys, and validates
12
+ * the issuer claim. Any verification failure returns `false` without throwing.
13
+ *
14
+ * @param config - Keycloak realm and client configuration.
15
+ * @returns An async {@link TokenVerifier} that returns `true` if the bearer
16
+ * JWT is valid, `false` otherwise.
17
+ */
18
+ export function createKeycloakVerifier(
19
+ config: KeycloakAuthConfig,
20
+ ): TokenVerifier {
21
+ const baseUrl = config.url.replace(/\/$/, "")
22
+ const jwksUri = new URL(
23
+ `/realms/${config.realm}/protocol/openid-connect/certs`,
24
+ baseUrl,
25
+ )
26
+ const JWKS = createRemoteJWKSet(jwksUri)
27
+ const issuer = `${baseUrl}/realms/${config.realm}`
28
+
29
+ return async function verifyToken(
30
+ authorizationHeader: string | undefined,
31
+ ): Promise<boolean> {
32
+ if (!authorizationHeader?.startsWith("Bearer ")) {
33
+ return false
34
+ }
35
+ const token = authorizationHeader.slice(7)
36
+ try {
37
+ await jwtVerify(token, JWKS, { issuer })
38
+ return true
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+ }
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs"
1
2
  import { join } from "node:path"
2
3
  import FastifyAccepts from "@fastify/accepts"
3
4
  import FastifyCompress from "@fastify/compress"
@@ -5,32 +6,137 @@ import FastifyCors from "@fastify/cors"
5
6
  import FastifyEtag from "@fastify/etag"
6
7
  import FastifyHelmet from "@fastify/helmet"
7
8
  import FastifyStatic from "@fastify/static"
9
+ import FastifySwagger from "@fastify/swagger"
10
+ import FastifySwaggerUi from "@fastify/swagger-ui"
8
11
  import FastifyView from "@fastify/view"
9
12
  import Ejs from "ejs"
10
- import type { FSTPlugin, LauncherLocals } from "../types.ts"
13
+ import type { OpenAPIV3_1 } from "openapi-types"
14
+ import { parse } from "yaml"
15
+ import type {
16
+ DefaultPluginsOptions,
17
+ FSTPlugin,
18
+ KeycloakAuthConfig,
19
+ } from "../types.ts"
20
+
21
+ function configureApiDocumentAuth(
22
+ apiDoc: OpenAPIV3_1.Document,
23
+ keycloakAuth?: KeycloakAuthConfig,
24
+ ): void {
25
+ if (!apiDoc.paths) {
26
+ return
27
+ }
28
+
29
+ const apiPath = apiDoc.paths["/api/"]
30
+ if (!apiPath) {
31
+ return
32
+ }
33
+
34
+ if (keycloakAuth) {
35
+ const baseUrl = keycloakAuth.url.replace(/\/$/, "")
36
+ const openIdConnectUrl = `${baseUrl}/realms/${keycloakAuth.realm}/.well-known/openid-configuration`
37
+
38
+ apiDoc.components ??= {}
39
+ apiDoc.components.securitySchemes = {
40
+ openIdConnect: {
41
+ type: "openIdConnect",
42
+ openIdConnectUrl,
43
+ },
44
+ }
45
+ apiDoc.security = [{ openIdConnect: [] }]
46
+
47
+ for (const operation of Object.values(apiPath)) {
48
+ if (
49
+ !operation ||
50
+ typeof operation !== "object" ||
51
+ !("responses" in operation)
52
+ ) {
53
+ continue
54
+ }
55
+
56
+ operation.security = [{ openIdConnect: [] }]
57
+ operation.responses ??= {}
58
+ operation.responses["401"] = {
59
+ description: "Unauthorized",
60
+ content: {
61
+ "application/json": {
62
+ schema: { $ref: "#/components/schemas/Error" },
63
+ examples: {
64
+ "401": { $ref: "#/components/examples/http_401" },
65
+ },
66
+ },
67
+ },
68
+ }
69
+ }
70
+ return
71
+ }
72
+
73
+ delete apiDoc.security
74
+ if (apiDoc.components?.securitySchemes) {
75
+ delete apiDoc.components.securitySchemes["openIdConnect"]
76
+ }
77
+ for (const operation of Object.values(apiPath)) {
78
+ if (
79
+ !operation ||
80
+ typeof operation !== "object" ||
81
+ !("responses" in operation)
82
+ ) {
83
+ continue
84
+ }
85
+
86
+ delete operation.security
87
+ delete operation.responses?.["401"]
88
+ }
89
+ }
11
90
 
12
91
  /**
13
92
  * Builds the default plugin map for use with `launcher`.
14
93
  *
15
94
  * Registers: `@fastify/accepts`, `@fastify/compress`,
16
95
  * `@fastify/cors`, `@fastify/etag`, `@fastify/helmet`,
17
- * `@fastify/view` (EJS), and `@fastify/static`.
96
+ * `@fastify/view` (EJS), `@fastify/static`, `@fastify/swagger`,
97
+ * and `@fastify/swagger-ui`.
98
+ *
99
+ * Synchronously reads and resolves all `$ref` schema files from
100
+ * `src/openapi/schemas/` before building the plugin map, so `@fastify/swagger`
101
+ * receives a fully inlined document.
18
102
  *
19
103
  * @param opts.locals - Application locals; `locals.pkg` is exposed as the
20
104
  * default context for every EJS view.
21
- * @param opts.baseDir - Optional base directory for resolving the `src/` folder; defaults to the parent of `import.meta.dirname`.
105
+ * @param opts.baseDir - Optional base directory for resolving the `src/`
106
+ * folder; defaults to the parent of `import.meta.dirname`.
107
+ * @param opts.keycloakAuth - Optional Keycloak configuration used to mark the
108
+ * generated `/api/` OpenAPI operations as OpenID Connect–protected.
22
109
  * @returns A `Map` of plugin names to plugin entries, suitable for passing as
23
110
  * the `plugins` field of `LauncherOptions`.
24
111
  */
25
- export default function defaultPlugins(opts: {
26
- locals: LauncherLocals
27
- baseDir?: string | null
28
- }): Map<string, FSTPlugin> {
112
+ export default function defaultPlugins(
113
+ opts: DefaultPluginsOptions,
114
+ ): Map<string, FSTPlugin> {
29
115
  const { locals, baseDir = null } = opts
30
116
  const plugins = new Map<string, FSTPlugin>()
31
117
  const srcDir = baseDir
32
118
  ? join(baseDir, "src")
33
119
  : join(import.meta.dirname, "..")
120
+ const apiDoc = parse(
121
+ readFileSync(join(srcDir, "openapi", "api.yaml"), "utf8"),
122
+ ) as OpenAPIV3_1.Document
123
+ const schemas = apiDoc.components?.schemas
124
+ if (schemas) {
125
+ for (const key of Object.keys(schemas)) {
126
+ const schema = schemas[key]
127
+ if (schema && "$ref" in schema) {
128
+ const refPath = join(
129
+ srcDir,
130
+ "openapi",
131
+ (schema as { $ref: string }).$ref,
132
+ )
133
+ schemas[key] = parse(
134
+ readFileSync(refPath, "utf8"),
135
+ ) as OpenAPIV3_1.SchemaObject
136
+ }
137
+ }
138
+ }
139
+ configureApiDocumentAuth(apiDoc, opts.keycloakAuth)
34
140
 
35
141
  plugins.set("@fastify/accepts", {
36
142
  plugin: FastifyAccepts,
@@ -52,6 +158,7 @@ export default function defaultPlugins(opts: {
52
158
  global: true,
53
159
  contentSecurityPolicy: {
54
160
  directives: {
161
+ connectSrc: ["'self'", "https://cdn.jsdelivr.net/"],
55
162
  fontSrc: [
56
163
  "'self'",
57
164
  "https://fonts.googleapis.com/",
@@ -85,6 +192,33 @@ export default function defaultPlugins(opts: {
85
192
  prefix: "/",
86
193
  },
87
194
  })
195
+ plugins.set("@fastify/swagger", {
196
+ plugin: FastifySwagger,
197
+ opts: {
198
+ mode: "static",
199
+ specification: { document: apiDoc },
200
+ exposeRoute: true,
201
+ },
202
+ })
203
+ plugins.set("@fastify/swagger-ui", {
204
+ plugin: FastifySwaggerUi,
205
+ opts: {
206
+ routePrefix: "/docs",
207
+ uiConfig: {
208
+ deepLinking: true,
209
+ docExpansion: "list",
210
+ dom_id: "#swagger-ui",
211
+ jsonEditor: true,
212
+ showRequestHeaders: true,
213
+ tryItOutEnabled: false,
214
+ onComplete: () => {
215
+ // @ts-expect-error — runs in browser context, not Node
216
+ const topbar = document.querySelector("div.topbar")
217
+ topbar?.remove()
218
+ },
219
+ },
220
+ },
221
+ })
88
222
 
89
223
  return plugins
90
224
  }
@@ -7,6 +7,11 @@ import type { RouteOptions } from "fastify"
7
7
  * Registers:
8
8
  * - `GET /` — renders `index.ejs` for `text/html`, throws 406 otherwise.
9
9
  * - `DELETE|PATCH|POST|PUT|OPTIONS /` — responds with 405 Method Not Allowed.
10
+ * - `GET /api/` — returns a JSON welcome message.
11
+ * - `DELETE|PATCH|POST|PUT /api/` — responds with 405 Method Not Allowed.
12
+ *
13
+ * Authentication is handled globally by the `preHandler` hook registered in
14
+ * {@link launcher} when `locals.authPaths` is set.
10
15
  */
11
16
  export default function defaultRoutes(): Map<string, RouteOptions> {
12
17
  const routes = new Map<string, RouteOptions>()
@@ -28,7 +33,6 @@ export default function defaultRoutes(): Map<string, RouteOptions> {
28
33
  }
29
34
  },
30
35
  })
31
-
32
36
  routes.set("INDEX_405", {
33
37
  method: ["DELETE", "PATCH", "POST", "PUT", "OPTIONS"],
34
38
  url: "/",
@@ -37,6 +41,31 @@ export default function defaultRoutes(): Map<string, RouteOptions> {
37
41
  throw methodNotAllowed()
38
42
  },
39
43
  })
44
+ routes.set("API_INDEX", {
45
+ method: "GET",
46
+ url: "/api/",
47
+ exposeHeadRoute: true,
48
+ handler: async (request, reply) => {
49
+ const { locals } = request.server
50
+ const accept = request.accepts()
51
+ switch (accept.type(["json"])) {
52
+ case "json":
53
+ return reply.type("application/json").send({
54
+ message: `Welcome to the index page of the server API :: ${locals.pkg?.["name"]}`,
55
+ })
56
+ default:
57
+ throw notAcceptable()
58
+ }
59
+ },
60
+ })
61
+ routes.set("API_INDEX_405", {
62
+ method: ["DELETE", "PATCH", "POST", "PUT"],
63
+ url: "/api/",
64
+ handler: async (_request, reply) => {
65
+ reply.header("allow", "GET, HEAD")
66
+ throw methodNotAllowed()
67
+ },
68
+ })
40
69
 
41
70
  return routes
42
71
  }
@@ -0,0 +1,41 @@
1
+ import { unauthorized } from "@hapi/boom"
2
+ import type { FastifyReply, FastifyRequest } from "fastify"
3
+ import picomatch from "picomatch"
4
+
5
+ /**
6
+ * Factory that creates a Fastify `preHandler` hook enforcing bearer-token
7
+ * authentication on any route whose URL matches one of the given glob patterns.
8
+ *
9
+ * The returned hook is intended to be registered globally via
10
+ * `fastify.addHook("preHandler", createAuthPreHandler(authPaths))`.
11
+ * For every request, `request.routeOptions.url` is tested against the compiled
12
+ * picomatch matcher; non-matching routes pass through unconditionally.
13
+ *
14
+ * When a route is protected, token verification is delegated to the
15
+ * `verifyToken` decorator registered on the Fastify instance.
16
+ *
17
+ * @param authPaths - Array of picomatch glob patterns (e.g. `["/api/**"]`).
18
+ * @param realm - Protection-space label used in the `WWW-Authenticate` challenge
19
+ * (RFC 6750). Typically the Keycloak realm name. Defaults to `"api"`.
20
+ */
21
+ export function createAuthPreHandler(
22
+ authPaths: string[],
23
+ realm = "api",
24
+ ): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {
25
+ const isProtected = picomatch(authPaths)
26
+ return async function authPreHandler(
27
+ request: FastifyRequest,
28
+ reply: FastifyReply,
29
+ ): Promise<void> {
30
+ if (!isProtected(request.routeOptions.url ?? "")) {
31
+ return
32
+ }
33
+ const isAuthorized = await request.server.verifyToken(
34
+ request.headers.authorization,
35
+ )
36
+ if (!isAuthorized) {
37
+ reply.header("www-authenticate", `Bearer realm="${realm}"`)
38
+ throw unauthorized("Missing or invalid bearer token")
39
+ }
40
+ }
41
+ }
package/src/index.ts CHANGED
@@ -8,16 +8,23 @@ import defaultErrorHandler from "./defaults/defaultErrorHandler.ts"
8
8
  import defaultFastifyOptions from "./defaults/defaultFastifyOptions.ts"
9
9
  import defaultPlugins from "./defaults/defaultPlugins.ts"
10
10
  import defaultRoutes from "./defaults/defaultRoutes.ts"
11
+ import { createAuthPreHandler } from "./hooks/authPreHandler.ts"
11
12
  import onResponse from "./hooks/onResponse.ts"
12
13
  import preHandler from "./hooks/preHandler.ts"
13
14
  import launcher from "./launcher.ts"
14
15
 
16
+ export { createKeycloakVerifier } from "./auth/keycloak.ts"
15
17
  export type {
18
+ DefaultPluginsOptions,
16
19
  FSTPlugin,
20
+ KeycloakAuthConfig,
17
21
  LauncherLocals,
18
22
  LauncherOptions,
23
+ TokenVerifier,
19
24
  } from "./types.ts"
25
+
20
26
  export {
27
+ createAuthPreHandler,
21
28
  defaultErrorHandler,
22
29
  defaultFastifyOptions,
23
30
  defaultPlugins,
package/src/launcher.ts CHANGED
@@ -3,6 +3,7 @@ import { notFound } from "@hapi/boom"
3
3
  import Fastify, { type FastifyInstance } from "fastify"
4
4
  import defaultErrorHandler from "./defaults/defaultErrorHandler.ts"
5
5
  import defaultFastifyOptions from "./defaults/defaultFastifyOptions.ts"
6
+ import { createAuthPreHandler } from "./hooks/authPreHandler.ts"
6
7
  import { onResponse, preHandler } from "./index.ts"
7
8
  import type { LauncherOptions } from "./types.ts"
8
9
 
@@ -11,7 +12,7 @@ import type { LauncherOptions } from "./types.ts"
11
12
  *
12
13
  * Steps performed:
13
14
  * 1. Creates a `FastifyInstance` merging {@link defaultFastifyOptions} with `opts`.
14
- * 2. Decorates the instance with `locals` and any extra `decorators`.
15
+ * 2. Decorates the instance with `locals`, `verifyToken`, and any extra `decorators`.
15
16
  * 3. Registers all `plugins` and `routes`.
16
17
  * 4. Sets a `notFound` handler (throws Boom 404) and {@link defaultErrorHandler}.
17
18
  * 5. Registers {@link preHandler} and {@link onResponse} hooks.
@@ -25,6 +26,7 @@ export default function launcher({
25
26
  plugins,
26
27
  routes,
27
28
  decorators,
29
+ verifyToken,
28
30
  opts,
29
31
  done,
30
32
  }: LauncherOptions): FastifyInstance {
@@ -37,6 +39,7 @@ export default function launcher({
37
39
  })
38
40
 
39
41
  fastify.decorate("locals", locals)
42
+ fastify.decorate("verifyToken", verifyToken ?? (async () => false))
40
43
 
41
44
  if (decorators instanceof Map) {
42
45
  for (const [key, value] of decorators) {
@@ -58,6 +61,13 @@ export default function launcher({
58
61
 
59
62
  fastify.setErrorHandler(defaultErrorHandler)
60
63
 
64
+ if (locals.authPaths?.length) {
65
+ fastify.addHook(
66
+ "preHandler",
67
+ createAuthPreHandler(locals.authPaths, locals.authRealm),
68
+ )
69
+ }
70
+
61
71
  // TODO: Add hook for `onRequestAbort`
62
72
  fastify.addHook("preHandler", preHandler)
63
73
  fastify.addHook("onResponse", onResponse)
@@ -0,0 +1,150 @@
1
+ openapi: 3.1.0
2
+
3
+ ##### Info
4
+ info:
5
+ title: Basic API
6
+ description: |
7
+ Reference implementation of the HTTP server.
8
+ version: 0.6.0
9
+
10
+ ##### Servers
11
+ servers:
12
+ - url: http://localhost:8888/
13
+ description: Local server
14
+
15
+ ##### Tags
16
+ tags:
17
+ - name: index
18
+ description: Operations related to the root of the API
19
+
20
+ ##### Paths
21
+ paths:
22
+ /api/:
23
+ summary: API index routes
24
+ get:
25
+ operationId: getApiIndex
26
+ summary: Returns a welcome message
27
+ tags:
28
+ - index
29
+ responses:
30
+ 200:
31
+ description: Welcome message
32
+ content:
33
+ application/json:
34
+ schema:
35
+ $ref: "#/components/schemas/WelcomeMessage"
36
+ head:
37
+ operationId: headApiIndex
38
+ summary: Returns headers for the API index
39
+ tags:
40
+ - index
41
+ responses:
42
+ 200:
43
+ description: Empty response with headers
44
+ delete:
45
+ operationId: deleteApiIndex
46
+ summary: Not allowed on the API index
47
+ tags:
48
+ - index
49
+ responses:
50
+ 405:
51
+ description: Method not allowed
52
+ content:
53
+ application/json:
54
+ schema:
55
+ $ref: "#/components/schemas/Error"
56
+ examples:
57
+ 405:
58
+ $ref: "#/components/examples/http_405"
59
+ patch:
60
+ operationId: patchApiIndex
61
+ summary: Not allowed on the API index
62
+ tags:
63
+ - index
64
+ responses:
65
+ 405:
66
+ description: Method not allowed
67
+ content:
68
+ application/json:
69
+ schema:
70
+ $ref: "#/components/schemas/Error"
71
+ examples:
72
+ 405:
73
+ $ref: "#/components/examples/http_405"
74
+ post:
75
+ operationId: postApiIndex
76
+ summary: Not allowed on the API index
77
+ tags:
78
+ - index
79
+ responses:
80
+ 405:
81
+ description: Method not allowed
82
+ content:
83
+ application/json:
84
+ schema:
85
+ $ref: "#/components/schemas/Error"
86
+ examples:
87
+ 405:
88
+ $ref: "#/components/examples/http_405"
89
+ put:
90
+ operationId: putApiIndex
91
+ summary: Not allowed on the API index
92
+ tags:
93
+ - index
94
+ responses:
95
+ 405:
96
+ description: Method not allowed
97
+ content:
98
+ application/json:
99
+ schema:
100
+ $ref: "#/components/schemas/Error"
101
+ examples:
102
+ 405:
103
+ $ref: "#/components/examples/http_405"
104
+
105
+ ##### Components
106
+ components:
107
+ securitySchemes:
108
+ openIdConnect:
109
+ type: openIdConnect
110
+ # Placeholder — overridden at runtime by defaultPlugins when KEYCLOAK_* env vars are set
111
+ openIdConnectUrl: https://keycloak.example.com/realms/placeholder/.well-known/openid-configuration
112
+ schemas:
113
+ Error:
114
+ $ref: "schemas/Error.yaml"
115
+ WelcomeMessage:
116
+ type: object
117
+ required: [message]
118
+ properties:
119
+ message:
120
+ description: Welcome message text.
121
+ type: string
122
+ examples:
123
+ http_400:
124
+ value:
125
+ code: 400
126
+ message: Bad Request
127
+ http_401:
128
+ value:
129
+ code: 401
130
+ message: Unauthorized
131
+ http_403:
132
+ value:
133
+ code: 403
134
+ message: Forbidden
135
+ http_404:
136
+ value:
137
+ code: 404
138
+ message: Not Found
139
+ http_405:
140
+ value:
141
+ code: 405
142
+ message: Method Not Allowed
143
+ http_406:
144
+ value:
145
+ code: 406
146
+ message: Not Acceptable
147
+ http_500:
148
+ value:
149
+ code: 500
150
+ message: Internal Server Error