@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.
- package/README.md +58 -8
- package/dist/__tests__/defaultPlugins.test.js +47 -2
- package/dist/__tests__/defaultPlugins.test.js.map +1 -1
- package/dist/__tests__/defaultRoutes.test.js +226 -1
- package/dist/__tests__/defaultRoutes.test.js.map +1 -1
- package/dist/__tests__/keycloak.test.d.ts +2 -0
- package/dist/__tests__/keycloak.test.d.ts.map +1 -0
- package/dist/__tests__/keycloak.test.js +105 -0
- package/dist/__tests__/keycloak.test.js.map +1 -0
- package/dist/__tests__/launcher.test.js +34 -0
- package/dist/__tests__/launcher.test.js.map +1 -1
- package/dist/auth/keycloak.d.ts +17 -0
- package/dist/auth/keycloak.d.ts.map +1 -0
- package/dist/auth/keycloak.js +35 -0
- package/dist/auth/keycloak.js.map +1 -0
- package/dist/defaults/defaultPlugins.d.ts +12 -7
- package/dist/defaults/defaultPlugins.d.ts.map +1 -1
- package/dist/defaults/defaultPlugins.js +109 -2
- package/dist/defaults/defaultPlugins.js.map +1 -1
- package/dist/defaults/defaultRoutes.d.ts +5 -0
- package/dist/defaults/defaultRoutes.d.ts.map +1 -1
- package/dist/defaults/defaultRoutes.js +30 -0
- package/dist/defaults/defaultRoutes.js.map +1 -1
- package/dist/hooks/authPreHandler.d.ts +19 -0
- package/dist/hooks/authPreHandler.d.ts.map +1 -0
- package/dist/hooks/authPreHandler.js +32 -0
- package/dist/hooks/authPreHandler.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/launcher.d.ts +2 -2
- package/dist/launcher.d.ts.map +1 -1
- package/dist/launcher.js +7 -2
- package/dist/launcher.js.map +1 -1
- package/dist/start.js +34 -5
- package/dist/start.js.map +1 -1
- package/dist/types.d.ts +64 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -3
- package/src/auth/keycloak.ts +43 -0
- package/src/defaults/defaultPlugins.ts +141 -7
- package/src/defaults/defaultRoutes.ts +30 -1
- package/src/hooks/authPreHandler.ts +41 -0
- package/src/index.ts +7 -0
- package/src/launcher.ts +11 -1
- package/src/openapi/api.yaml +150 -0
- package/src/openapi/schemas/Error.yaml +14 -0
- package/src/start.ts +44 -5
- package/src/types.ts +71 -2
- 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?:
|
|
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
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
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 {
|
|
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),
|
|
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/`
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
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
|