@rt-tools/auth-server 0.0.0-stage → 0.2.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 +92 -2
- package/cjs/index.js +14 -0
- package/cjs/index.js.map +1 -0
- package/cjs/lib/access-audit.js +39 -0
- package/cjs/lib/access-audit.js.map +1 -0
- package/cjs/lib/access.js +36 -0
- package/cjs/lib/access.js.map +1 -0
- package/cjs/lib/auth-server.module.js +71 -0
- package/cjs/lib/auth-server.module.js.map +1 -0
- package/cjs/lib/auth.guard.js +51 -0
- package/cjs/lib/auth.guard.js.map +1 -0
- package/cjs/lib/auth.tokens.js +10 -0
- package/cjs/lib/auth.tokens.js.map +1 -0
- package/cjs/lib/catalog-sync.js +51 -0
- package/cjs/lib/catalog-sync.js.map +1 -0
- package/cjs/lib/connect-access.js +54 -0
- package/cjs/lib/connect-access.js.map +1 -0
- package/cjs/lib/current-caller.decorator.js +14 -0
- package/cjs/lib/current-caller.decorator.js.map +1 -0
- package/cjs/lib/env-options.js +77 -0
- package/cjs/lib/env-options.js.map +1 -0
- package/cjs/lib/testing/keys.js +28 -0
- package/cjs/lib/testing/keys.js.map +1 -0
- package/cjs/lib/token-verifier.js +77 -0
- package/cjs/lib/token-verifier.js.map +1 -0
- package/cjs/package.json +3 -0
- package/esm/index.d.ts +11 -0
- package/esm/index.d.ts.map +1 -0
- package/esm/index.js +11 -0
- package/esm/index.js.map +1 -0
- package/esm/lib/access-audit.d.ts +13 -0
- package/esm/lib/access-audit.d.ts.map +1 -0
- package/esm/lib/access-audit.js +35 -0
- package/esm/lib/access-audit.js.map +1 -0
- package/esm/lib/access.d.ts +31 -0
- package/esm/lib/access.d.ts.map +1 -0
- package/esm/lib/access.js +29 -0
- package/esm/lib/access.js.map +1 -0
- package/esm/lib/auth-server.module.d.ts +28 -0
- package/esm/lib/auth-server.module.d.ts.map +1 -0
- package/esm/lib/auth-server.module.js +67 -0
- package/esm/lib/auth-server.module.js.map +1 -0
- package/esm/lib/auth.guard.d.ts +15 -0
- package/esm/lib/auth.guard.d.ts.map +1 -0
- package/esm/lib/auth.guard.js +48 -0
- package/esm/lib/auth.guard.js.map +1 -0
- package/esm/lib/auth.tokens.d.ts +7 -0
- package/esm/lib/auth.tokens.d.ts.map +1 -0
- package/esm/lib/auth.tokens.js +7 -0
- package/esm/lib/auth.tokens.js.map +1 -0
- package/esm/lib/catalog-sync.d.ts +27 -0
- package/esm/lib/catalog-sync.d.ts.map +1 -0
- package/esm/lib/catalog-sync.js +48 -0
- package/esm/lib/catalog-sync.js.map +1 -0
- package/esm/lib/connect-access.d.ts +35 -0
- package/esm/lib/connect-access.d.ts.map +1 -0
- package/esm/lib/connect-access.js +49 -0
- package/esm/lib/connect-access.js.map +1 -0
- package/esm/lib/current-caller.decorator.d.ts +6 -0
- package/esm/lib/current-caller.decorator.d.ts.map +1 -0
- package/esm/lib/current-caller.decorator.js +10 -0
- package/esm/lib/current-caller.decorator.js.map +1 -0
- package/esm/lib/env-options.d.ts +44 -0
- package/esm/lib/env-options.d.ts.map +1 -0
- package/esm/lib/env-options.js +71 -0
- package/esm/lib/env-options.js.map +1 -0
- package/esm/lib/testing/keys.d.ts +17 -0
- package/esm/lib/testing/keys.d.ts.map +1 -0
- package/esm/lib/testing/keys.js +23 -0
- package/esm/lib/testing/keys.js.map +1 -0
- package/esm/lib/token-verifier.d.ts +41 -0
- package/esm/lib/token-verifier.d.ts.map +1 -0
- package/esm/lib/token-verifier.js +72 -0
- package/esm/lib/token-verifier.js.map +1 -0
- package/esm/package.json +3 -0
- package/package.json +48 -4
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KeycloakTokenVerifier = void 0;
|
|
4
|
+
exports.bearerToken = bearerToken;
|
|
5
|
+
const jose_1 = require("jose");
|
|
6
|
+
const auth_contract_1 = require("@rt-tools/auth-contract");
|
|
7
|
+
const BEARER = /^Bearer ([A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)$/;
|
|
8
|
+
/** The token of the `Authorization` header, or `null` when the header is absent or of another form. */
|
|
9
|
+
function bearerToken(authorization) {
|
|
10
|
+
const match = BEARER.exec(authorization?.trim() ?? '');
|
|
11
|
+
return match ? match[1] : null;
|
|
12
|
+
}
|
|
13
|
+
function resourceAccessOf(value) {
|
|
14
|
+
if (typeof value !== 'object' || value === null) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const access = {};
|
|
18
|
+
for (const [clientId, entry] of Object.entries(value)) {
|
|
19
|
+
const roles = typeof entry === 'object' && entry !== null ? Reflect.get(entry, 'roles') : undefined;
|
|
20
|
+
access[clientId] = { roles: Array.isArray(roles) ? roles.filter((role) => typeof role === 'string') : [] };
|
|
21
|
+
}
|
|
22
|
+
return access;
|
|
23
|
+
}
|
|
24
|
+
/** The claims the caller is read from, taken from a verified payload by their shape. */
|
|
25
|
+
function claimsOf(payload) {
|
|
26
|
+
return {
|
|
27
|
+
sub: String(payload.sub),
|
|
28
|
+
email: typeof payload['email'] === 'string' ? payload['email'] : undefined,
|
|
29
|
+
email_verified: payload['email_verified'] === true,
|
|
30
|
+
name: typeof payload['name'] === 'string' ? payload['name'] : undefined,
|
|
31
|
+
preferred_username: typeof payload['preferred_username'] === 'string' ? payload['preferred_username'] : undefined,
|
|
32
|
+
resource_access: resourceAccessOf(payload['resource_access']),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The token check of one admin, shared by the NestJS guard and the Connect interceptor.
|
|
37
|
+
*
|
|
38
|
+
* A token is accepted when a key of the realm signed it, the realm issued it, its term holds and
|
|
39
|
+
* it was issued to the client of this admin or to a service client it names (`azp`). Every other
|
|
40
|
+
* case answers `null` and nothing
|
|
41
|
+
* more: the reason of a refusal is not told to the caller, so the answers do not show which tokens
|
|
42
|
+
* come close.
|
|
43
|
+
*
|
|
44
|
+
* The keys are fetched from the key set address of the realm and cached by `jose`; a key the
|
|
45
|
+
* realm rotates in is picked up without a restart.
|
|
46
|
+
*/
|
|
47
|
+
class KeycloakTokenVerifier {
|
|
48
|
+
#options;
|
|
49
|
+
#keys;
|
|
50
|
+
constructor(options, keys) {
|
|
51
|
+
this.#options = options;
|
|
52
|
+
this.#keys = keys ?? (0, jose_1.createRemoteJWKSet)(new URL(options.keysUrl ?? `${options.issuer}/protocol/openid-connect/certs`));
|
|
53
|
+
}
|
|
54
|
+
/** The caller of the `Authorization` header, or `null` when the token is not accepted. */
|
|
55
|
+
async callerOf(authorization) {
|
|
56
|
+
const token = bearerToken(authorization);
|
|
57
|
+
if (!token) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const { payload } = await (0, jose_1.jwtVerify)(token, this.#keys, { issuer: this.#options.issuer });
|
|
62
|
+
if (!this.#accepts(payload['azp']) || typeof payload.sub !== 'string') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return (0, auth_contract_1.callerFromClaims)(claimsOf(payload), this.#options.clientId);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Whether a token issued to this client is accepted: the client of this admin or a service client it names. */
|
|
72
|
+
#accepts(azp) {
|
|
73
|
+
return azp === this.#options.clientId || (typeof azp === 'string' && (this.#options.serviceClients ?? []).includes(azp));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.KeycloakTokenVerifier = KeycloakTokenVerifier;
|
|
77
|
+
//# sourceMappingURL=token-verifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-verifier.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/token-verifier.ts"],"names":[],"mappings":"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"}
|
package/cjs/package.json
ADDED
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './lib/access.js';
|
|
2
|
+
export * from './lib/access-audit.js';
|
|
3
|
+
export * from './lib/auth-server.module.js';
|
|
4
|
+
export * from './lib/auth.guard.js';
|
|
5
|
+
export * from './lib/auth.tokens.js';
|
|
6
|
+
export * from './lib/catalog-sync.js';
|
|
7
|
+
export * from './lib/connect-access.js';
|
|
8
|
+
export * from './lib/current-caller.decorator.js';
|
|
9
|
+
export * from './lib/env-options.js';
|
|
10
|
+
export * from './lib/token-verifier.js';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../projects/auth-server/src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mCAAmC,CAAC;AAClD,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC"}
|
package/esm/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './lib/access.js';
|
|
2
|
+
export * from './lib/access-audit.js';
|
|
3
|
+
export * from './lib/auth-server.module.js';
|
|
4
|
+
export * from './lib/auth.guard.js';
|
|
5
|
+
export * from './lib/auth.tokens.js';
|
|
6
|
+
export * from './lib/catalog-sync.js';
|
|
7
|
+
export * from './lib/connect-access.js';
|
|
8
|
+
export * from './lib/current-caller.decorator.js';
|
|
9
|
+
export * from './lib/env-options.js';
|
|
10
|
+
export * from './lib/token-verifier.js';
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
package/esm/index.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../projects/auth-server/src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mCAAmC,CAAC;AAClD,cAAc,sBAAsB,CAAC;AACrC,cAAc,yBAAyB,CAAC","sourcesContent":["export * from './lib/access.js';\nexport * from './lib/access-audit.js';\nexport * from './lib/auth-server.module.js';\nexport * from './lib/auth.guard.js';\nexport * from './lib/auth.tokens.js';\nexport * from './lib/catalog-sync.js';\nexport * from './lib/connect-access.js';\nexport * from './lib/current-caller.decorator.js';\nexport * from './lib/env-options.js';\nexport * from './lib/token-verifier.js';\n"]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A class whose prototype holds the route handlers. */
|
|
2
|
+
export type TControllerClass = abstract new (...args: never[]) => object;
|
|
3
|
+
/**
|
|
4
|
+
* The route handlers of the controllers that declare no access or more than one, as
|
|
5
|
+
* `Controller.method — none` / `— 2`.
|
|
6
|
+
*
|
|
7
|
+
* A handler is a prototype method with a route mark of the framework. An empty list means every
|
|
8
|
+
* handler declares exactly one access.
|
|
9
|
+
*/
|
|
10
|
+
export declare function undeclaredAccess(controllers: readonly TControllerClass[]): readonly string[];
|
|
11
|
+
/** The refusal of the start, naming every faulty operation at once. */
|
|
12
|
+
export declare function accessAuditError(faults: readonly string[]): Error;
|
|
13
|
+
//# sourceMappingURL=access-audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-audit.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/access-audit.ts"],"names":[],"mappings":"AAIA,wDAAwD;AACxD,MAAM,MAAM,gBAAgB,GAAG,QAAQ,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,MAAM,CAAC;AAMzE;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,SAAS,gBAAgB,EAAE,GAAG,SAAS,MAAM,EAAE,CAgB5F;AAED,uEAAuE;AACvE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,KAAK,CAGjE"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
2
|
+
import { accessDeclarationsOf } from './access';
|
|
3
|
+
function isRoute(handler) {
|
|
4
|
+
return Reflect.getMetadata(PATH_METADATA, handler) !== undefined && Reflect.getMetadata(METHOD_METADATA, handler) !== undefined;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The route handlers of the controllers that declare no access or more than one, as
|
|
8
|
+
* `Controller.method — none` / `— 2`.
|
|
9
|
+
*
|
|
10
|
+
* A handler is a prototype method with a route mark of the framework. An empty list means every
|
|
11
|
+
* handler declares exactly one access.
|
|
12
|
+
*/
|
|
13
|
+
export function undeclaredAccess(controllers) {
|
|
14
|
+
const faults = [];
|
|
15
|
+
for (const controller of controllers) {
|
|
16
|
+
const prototype = controller.prototype;
|
|
17
|
+
for (const name of Object.getOwnPropertyNames(prototype)) {
|
|
18
|
+
const handler = Reflect.get(prototype, name);
|
|
19
|
+
if (name === 'constructor' || typeof handler !== 'function' || !isRoute(handler)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const count = accessDeclarationsOf(handler).length;
|
|
23
|
+
if (count !== 1) {
|
|
24
|
+
faults.push(`${controller.name}.${name} — ${count === 0 ? 'none' : String(count)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return faults;
|
|
29
|
+
}
|
|
30
|
+
/** The refusal of the start, naming every faulty operation at once. */
|
|
31
|
+
export function accessAuditError(faults) {
|
|
32
|
+
const lines = faults.map((fault) => ' ' + fault).join('\n');
|
|
33
|
+
return new Error(`Every operation declares exactly one access; these do not:\n${lines}`);
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=access-audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-audit.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/access-audit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE1E,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAKhD,SAAS,OAAO,CAAC,OAAe;IAC5B,OAAO,OAAO,CAAC,WAAW,CAAC,aAAa,EAAE,OAAO,CAAC,KAAK,SAAS,IAAI,OAAO,CAAC,WAAW,CAAC,eAAe,EAAE,OAAO,CAAC,KAAK,SAAS,CAAC;AACpI,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,WAAwC;IACrE,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACnC,MAAM,SAAS,GAAW,UAAU,CAAC,SAAmB,CAAC;QACzD,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,mBAAmB,CAAC,SAAS,CAAC,EAAE,CAAC;YACvD,MAAM,OAAO,GAAY,OAAO,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;YACtD,IAAI,IAAI,KAAK,aAAa,IAAI,OAAO,OAAO,KAAK,UAAU,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/E,SAAS;YACb,CAAC;YACD,MAAM,KAAK,GAAW,oBAAoB,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;YAC3D,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;gBACd,MAAM,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,IAAI,IAAI,IAAI,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YACxF,CAAC;QACL,CAAC;IACL,CAAC;IACD,OAAO,MAAM,CAAC;AAClB,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,gBAAgB,CAAC,MAAyB;IACtD,MAAM,KAAK,GAAW,MAAM,CAAC,GAAG,CAAC,CAAC,KAAa,EAAU,EAAE,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrF,OAAO,IAAI,KAAK,CAAC,+DAA+D,KAAK,EAAE,CAAC,CAAC;AAC7F,CAAC","sourcesContent":["import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';\n\nimport { accessDeclarationsOf } from './access';\n\n/** A class whose prototype holds the route handlers. */\nexport type TControllerClass = abstract new (...args: never[]) => object;\n\nfunction isRoute(handler: object): boolean {\n return Reflect.getMetadata(PATH_METADATA, handler) !== undefined && Reflect.getMetadata(METHOD_METADATA, handler) !== undefined;\n}\n\n/**\n * The route handlers of the controllers that declare no access or more than one, as\n * `Controller.method — none` / `— 2`.\n *\n * A handler is a prototype method with a route mark of the framework. An empty list means every\n * handler declares exactly one access.\n */\nexport function undeclaredAccess(controllers: readonly TControllerClass[]): readonly string[] {\n const faults: string[] = [];\n for (const controller of controllers) {\n const prototype: object = controller.prototype as object;\n for (const name of Object.getOwnPropertyNames(prototype)) {\n const handler: unknown = Reflect.get(prototype, name);\n if (name === 'constructor' || typeof handler !== 'function' || !isRoute(handler)) {\n continue;\n }\n const count: number = accessDeclarationsOf(handler).length;\n if (count !== 1) {\n faults.push(`${controller.name}.${name} — ${count === 0 ? 'none' : String(count)}`);\n }\n }\n }\n return faults;\n}\n\n/** The refusal of the start, naming every faulty operation at once. */\nexport function accessAuditError(faults: readonly string[]): Error {\n const lines: string = faults.map((fault: string): string => ' ' + fault).join('\\n');\n return new Error(`Every operation declares exactly one access; these do not:\\n${lines}`);\n}\n"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { TPermission } from '@rt-tools/auth-contract';
|
|
3
|
+
/**
|
|
4
|
+
* What an operation is open to. Every operation declares exactly one: the start audit refuses an
|
|
5
|
+
* operation that declares none or two, so a forgotten declaration never opens anything.
|
|
6
|
+
*/
|
|
7
|
+
export type TAccess =
|
|
8
|
+
/** Open to everyone, without a token. */
|
|
9
|
+
{
|
|
10
|
+
readonly kind: 'public';
|
|
11
|
+
}
|
|
12
|
+
/** Open to anyone with an accepted token. */
|
|
13
|
+
| {
|
|
14
|
+
readonly kind: 'signed-in';
|
|
15
|
+
}
|
|
16
|
+
/** Open to a caller with the right. */
|
|
17
|
+
| {
|
|
18
|
+
readonly kind: 'permission';
|
|
19
|
+
readonly permission: TPermission;
|
|
20
|
+
};
|
|
21
|
+
/** The key the declarations of a handler lie under: a list, so a second one is seen, not lost. */
|
|
22
|
+
export declare const ACCESS_DECLARATIONS: string;
|
|
23
|
+
/** The declarations of a handler, in the order written. */
|
|
24
|
+
export declare function accessDeclarationsOf(handler: object): readonly TAccess[];
|
|
25
|
+
/** The operation is open to everyone, without a token. */
|
|
26
|
+
export declare function OpenOperation(): MethodDecorator;
|
|
27
|
+
/** The operation is open to anyone with an accepted token. */
|
|
28
|
+
export declare function SignedInOperation(): MethodDecorator;
|
|
29
|
+
/** The operation is open to a caller with the right. */
|
|
30
|
+
export declare function PermittedOperation(permission: TPermission): MethodDecorator;
|
|
31
|
+
//# sourceMappingURL=access.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/access.ts"],"names":[],"mappings":"AAAA,OAAO,kBAAkB,CAAC;AAE1B,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEtD;;;GAGG;AACH,MAAM,MAAM,OAAO;AACf,yCAAyC;AACvC;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE;AAC7B,6CAA6C;GAC3C;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAA;CAAE;AAChC,uCAAuC;GACrC;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,WAAW,CAAA;CAAE,CAAC;AAExE,kGAAkG;AAClG,eAAO,MAAM,mBAAmB,EAAE,MAA+B,CAAC;AAElE,2DAA2D;AAC3D,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,OAAO,EAAE,CAGxE;AAWD,0DAA0D;AAC1D,wBAAgB,aAAa,IAAI,eAAe,CAE/C;AAED,8DAA8D;AAC9D,wBAAgB,iBAAiB,IAAI,eAAe,CAEnD;AAED,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,WAAW,GAAG,eAAe,CAE3E"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
/** The key the declarations of a handler lie under: a list, so a second one is seen, not lost. */
|
|
3
|
+
export const ACCESS_DECLARATIONS = 'rt-tools.auth.access';
|
|
4
|
+
/** The declarations of a handler, in the order written. */
|
|
5
|
+
export function accessDeclarationsOf(handler) {
|
|
6
|
+
const declared = Reflect.getMetadata(ACCESS_DECLARATIONS, handler);
|
|
7
|
+
return Array.isArray(declared) ? declared : [];
|
|
8
|
+
}
|
|
9
|
+
function declare(access) {
|
|
10
|
+
return (_target, _key, descriptor) => {
|
|
11
|
+
const handler = descriptor.value;
|
|
12
|
+
Reflect.defineMetadata(ACCESS_DECLARATIONS, [...accessDeclarationsOf(handler), access], handler);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/* eslint-disable sonarjs/function-name -- decorator factories are named with a capital letter: so the framework names them, and so they read at the place of use */
|
|
16
|
+
/** The operation is open to everyone, without a token. */
|
|
17
|
+
export function OpenOperation() {
|
|
18
|
+
return declare({ kind: 'public' });
|
|
19
|
+
}
|
|
20
|
+
/** The operation is open to anyone with an accepted token. */
|
|
21
|
+
export function SignedInOperation() {
|
|
22
|
+
return declare({ kind: 'signed-in' });
|
|
23
|
+
}
|
|
24
|
+
/** The operation is open to a caller with the right. */
|
|
25
|
+
export function PermittedOperation(permission) {
|
|
26
|
+
return declare({ kind: 'permission', permission });
|
|
27
|
+
}
|
|
28
|
+
/* eslint-enable sonarjs/function-name */
|
|
29
|
+
//# sourceMappingURL=access.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/access.ts"],"names":[],"mappings":"AAAA,OAAO,kBAAkB,CAAC;AAgB1B,kGAAkG;AAClG,MAAM,CAAC,MAAM,mBAAmB,GAAW,sBAAsB,CAAC;AAElE,2DAA2D;AAC3D,MAAM,UAAU,oBAAoB,CAAC,OAAe;IAChD,MAAM,QAAQ,GAAY,OAAO,CAAC,WAAW,CAAC,mBAAmB,EAAE,OAAO,CAAC,CAAC;IAC5E,OAAO,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAE,QAAsB,CAAC,CAAC,CAAC,EAAE,CAAC;AAClE,CAAC;AAED,SAAS,OAAO,CAAC,MAAe;IAC5B,OAAO,CAAC,OAAe,EAAE,IAAqB,EAAE,UAA8B,EAAQ,EAAE;QACpF,MAAM,OAAO,GAAW,UAAU,CAAC,KAAe,CAAC;QACnD,OAAO,CAAC,cAAc,CAAC,mBAAmB,EAAE,CAAC,GAAG,oBAAoB,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;IACrG,CAAC,CAAC;AACN,CAAC;AAED,oKAAoK;AAEpK,0DAA0D;AAC1D,MAAM,UAAU,aAAa;IACzB,OAAO,OAAO,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC;AACvC,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,iBAAiB;IAC7B,OAAO,OAAO,CAAC,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;AAC1C,CAAC;AAED,wDAAwD;AACxD,MAAM,UAAU,kBAAkB,CAAC,UAAuB;IACtD,OAAO,OAAO,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,UAAU,EAAE,CAAC,CAAC;AACvD,CAAC;AAED,yCAAyC","sourcesContent":["import 'reflect-metadata';\n\nimport { TPermission } from '@rt-tools/auth-contract';\n\n/**\n * What an operation is open to. Every operation declares exactly one: the start audit refuses an\n * operation that declares none or two, so a forgotten declaration never opens anything.\n */\nexport type TAccess =\n /** Open to everyone, without a token. */\n | { readonly kind: 'public' }\n /** Open to anyone with an accepted token. */\n | { readonly kind: 'signed-in' }\n /** Open to a caller with the right. */\n | { readonly kind: 'permission'; readonly permission: TPermission };\n\n/** The key the declarations of a handler lie under: a list, so a second one is seen, not lost. */\nexport const ACCESS_DECLARATIONS: string = 'rt-tools.auth.access';\n\n/** The declarations of a handler, in the order written. */\nexport function accessDeclarationsOf(handler: object): readonly TAccess[] {\n const declared: unknown = Reflect.getMetadata(ACCESS_DECLARATIONS, handler);\n return Array.isArray(declared) ? (declared as TAccess[]) : [];\n}\n\nfunction declare(access: TAccess): MethodDecorator {\n return (_target: object, _key: string | symbol, descriptor: PropertyDescriptor): void => {\n const handler: object = descriptor.value as object;\n Reflect.defineMetadata(ACCESS_DECLARATIONS, [...accessDeclarationsOf(handler), access], handler);\n };\n}\n\n/* eslint-disable sonarjs/function-name -- decorator factories are named with a capital letter: so the framework names them, and so they read at the place of use */\n\n/** The operation is open to everyone, without a token. */\nexport function OpenOperation(): MethodDecorator {\n return declare({ kind: 'public' });\n}\n\n/** The operation is open to anyone with an accepted token. */\nexport function SignedInOperation(): MethodDecorator {\n return declare({ kind: 'signed-in' });\n}\n\n/** The operation is open to a caller with the right. */\nexport function PermittedOperation(permission: TPermission): MethodDecorator {\n return declare({ kind: 'permission', permission });\n}\n\n/* eslint-enable sonarjs/function-name */\n"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DynamicModule, OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService } from '@nestjs/core';
|
|
3
|
+
import { TPermission } from '@rt-tools/auth-contract';
|
|
4
|
+
import { ICatalogSyncOptions } from './catalog-sync';
|
|
5
|
+
import { ITokenCheckOptions } from './token-verifier';
|
|
6
|
+
/** What an admin server tells the module. */
|
|
7
|
+
export interface IAuthServerOptions extends ITokenCheckOptions {
|
|
8
|
+
/** Every right of this admin — `definePermissions(…)` of the contract. */
|
|
9
|
+
readonly catalog: readonly TPermission[];
|
|
10
|
+
/** Where the catalog is sent at start; without it nothing is sent. */
|
|
11
|
+
readonly sync?: Omit<ICatalogSyncOptions, 'clientId'>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* At start: every operation declares exactly one access, and the catalog reaches Keycloak.
|
|
15
|
+
*
|
|
16
|
+
* An operation without a declaration or with two stops the start and is named; the catalog sync
|
|
17
|
+
* creates the roles Keycloak lacks and names the extra ones in the log.
|
|
18
|
+
*/
|
|
19
|
+
export declare class AuthStartCheck implements OnApplicationBootstrap {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(discovery: DiscoveryService, options: IAuthServerOptions);
|
|
22
|
+
onApplicationBootstrap(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** The entry module of an admin server: the token check, the guard and the start check. */
|
|
25
|
+
export declare class AuthServerModule {
|
|
26
|
+
static forRoot(options: IAuthServerOptions): DynamicModule;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=auth-server.module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-server.module.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/auth-server.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAkB,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACvF,OAAO,EAA8B,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAG5E,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAKtD,OAAO,EAAE,mBAAmB,EAA6C,MAAM,gBAAgB,CAAC;AAChG,OAAO,EAAE,kBAAkB,EAAyB,MAAM,kBAAkB,CAAC;AAE7E,6CAA6C;AAC7C,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC1D,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,CAAC;IACzC,sEAAsE;IACtE,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,mBAAmB,EAAE,UAAU,CAAC,CAAC;CACzD;AAED;;;;;GAKG;AACH,qBAAa,cAAe,YAAW,sBAAsB;;gBAK7C,SAAS,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB;IAKvD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;CAoBvD;AAED,2FAA2F;AAC3F,qBACa,gBAAgB;WACX,OAAO,CAAC,OAAO,EAAE,kBAAkB,GAAG,aAAa;CAkBpE"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
var AuthServerModule_1;
|
|
2
|
+
import { __decorate } from "tslib";
|
|
3
|
+
import { Logger, Module } from '@nestjs/common';
|
|
4
|
+
import { APP_GUARD, DiscoveryModule, DiscoveryService } from '@nestjs/core';
|
|
5
|
+
import { accessAuditError, undeclaredAccess } from './access-audit';
|
|
6
|
+
import { AuthGuard } from './auth.guard';
|
|
7
|
+
import { AUTH_SERVER_OPTIONS, AUTH_TOKEN_VERIFIER } from './auth.tokens';
|
|
8
|
+
import { syncPermissionCatalog } from './catalog-sync';
|
|
9
|
+
import { KeycloakTokenVerifier } from './token-verifier';
|
|
10
|
+
/**
|
|
11
|
+
* At start: every operation declares exactly one access, and the catalog reaches Keycloak.
|
|
12
|
+
*
|
|
13
|
+
* An operation without a declaration or with two stops the start and is named; the catalog sync
|
|
14
|
+
* creates the roles Keycloak lacks and names the extra ones in the log.
|
|
15
|
+
*/
|
|
16
|
+
export class AuthStartCheck {
|
|
17
|
+
#discovery;
|
|
18
|
+
#options;
|
|
19
|
+
#logger = new Logger('AuthServer');
|
|
20
|
+
constructor(discovery, options) {
|
|
21
|
+
this.#discovery = discovery;
|
|
22
|
+
this.#options = options;
|
|
23
|
+
}
|
|
24
|
+
async onApplicationBootstrap() {
|
|
25
|
+
const controllers = this.#discovery
|
|
26
|
+
.getControllers()
|
|
27
|
+
.map((wrapper) => wrapper.metatype)
|
|
28
|
+
.filter((metatype) => typeof metatype === 'function');
|
|
29
|
+
const faults = undeclaredAccess(controllers);
|
|
30
|
+
if (faults.length) {
|
|
31
|
+
throw accessAuditError(faults);
|
|
32
|
+
}
|
|
33
|
+
if (this.#options.sync) {
|
|
34
|
+
const result = await syncPermissionCatalog({ ...this.#options.sync, clientId: this.#options.clientId }, this.#options.catalog);
|
|
35
|
+
this.#logger.log(`catalog: ${this.#options.catalog.length} rights, created ${result.created.length}`);
|
|
36
|
+
if (result.extra.length) {
|
|
37
|
+
this.#logger.warn(`Keycloak holds rights the catalog lacks: ${result.extra.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The entry module of an admin server: the token check, the guard and the start check. */
|
|
43
|
+
let AuthServerModule = AuthServerModule_1 = class AuthServerModule {
|
|
44
|
+
static forRoot(options) {
|
|
45
|
+
return {
|
|
46
|
+
module: AuthServerModule_1,
|
|
47
|
+
global: true,
|
|
48
|
+
imports: [DiscoveryModule],
|
|
49
|
+
providers: [
|
|
50
|
+
{ provide: AUTH_SERVER_OPTIONS, useValue: options },
|
|
51
|
+
{ provide: AUTH_TOKEN_VERIFIER, useValue: new KeycloakTokenVerifier(options) },
|
|
52
|
+
{ provide: APP_GUARD, useClass: AuthGuard },
|
|
53
|
+
{
|
|
54
|
+
provide: AuthStartCheck,
|
|
55
|
+
useFactory: (discovery) => new AuthStartCheck(discovery, options),
|
|
56
|
+
inject: [DiscoveryService],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
exports: [AUTH_SERVER_OPTIONS, AUTH_TOKEN_VERIFIER],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
AuthServerModule = AuthServerModule_1 = __decorate([
|
|
64
|
+
Module({})
|
|
65
|
+
], AuthServerModule);
|
|
66
|
+
export { AuthServerModule };
|
|
67
|
+
//# sourceMappingURL=auth-server.module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-server.module.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/auth-server.module.ts"],"names":[],"mappings":"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","sourcesContent":["import { DynamicModule, Logger, Module, OnApplicationBootstrap } from '@nestjs/common';\nimport { APP_GUARD, DiscoveryModule, DiscoveryService } from '@nestjs/core';\nimport { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';\n\nimport { TPermission } from '@rt-tools/auth-contract';\n\nimport { accessAuditError, TControllerClass, undeclaredAccess } from './access-audit';\nimport { AuthGuard } from './auth.guard';\nimport { AUTH_SERVER_OPTIONS, AUTH_TOKEN_VERIFIER } from './auth.tokens';\nimport { ICatalogSyncOptions, ICatalogSyncResult, syncPermissionCatalog } from './catalog-sync';\nimport { ITokenCheckOptions, KeycloakTokenVerifier } from './token-verifier';\n\n/** What an admin server tells the module. */\nexport interface IAuthServerOptions extends ITokenCheckOptions {\n /** Every right of this admin — `definePermissions(…)` of the contract. */\n readonly catalog: readonly TPermission[];\n /** Where the catalog is sent at start; without it nothing is sent. */\n readonly sync?: Omit<ICatalogSyncOptions, 'clientId'>;\n}\n\n/**\n * At start: every operation declares exactly one access, and the catalog reaches Keycloak.\n *\n * An operation without a declaration or with two stops the start and is named; the catalog sync\n * creates the roles Keycloak lacks and names the extra ones in the log.\n */\nexport class AuthStartCheck implements OnApplicationBootstrap {\n readonly #discovery: DiscoveryService;\n readonly #options: IAuthServerOptions;\n readonly #logger: Logger = new Logger('AuthServer');\n\n constructor(discovery: DiscoveryService, options: IAuthServerOptions) {\n this.#discovery = discovery;\n this.#options = options;\n }\n\n public async onApplicationBootstrap(): Promise<void> {\n const controllers: TControllerClass[] = this.#discovery\n .getControllers()\n .map((wrapper: InstanceWrapper): unknown => wrapper.metatype)\n .filter((metatype: unknown): metatype is TControllerClass => typeof metatype === 'function');\n const faults: readonly string[] = undeclaredAccess(controllers);\n if (faults.length) {\n throw accessAuditError(faults);\n }\n if (this.#options.sync) {\n const result: ICatalogSyncResult = await syncPermissionCatalog(\n { ...this.#options.sync, clientId: this.#options.clientId },\n this.#options.catalog\n );\n this.#logger.log(`catalog: ${this.#options.catalog.length} rights, created ${result.created.length}`);\n if (result.extra.length) {\n this.#logger.warn(`Keycloak holds rights the catalog lacks: ${result.extra.join(', ')}`);\n }\n }\n }\n}\n\n/** The entry module of an admin server: the token check, the guard and the start check. */\n@Module({})\nexport class AuthServerModule {\n public static forRoot(options: IAuthServerOptions): DynamicModule {\n return {\n module: AuthServerModule,\n global: true,\n imports: [DiscoveryModule],\n providers: [\n { provide: AUTH_SERVER_OPTIONS, useValue: options },\n { provide: AUTH_TOKEN_VERIFIER, useValue: new KeycloakTokenVerifier(options) },\n { provide: APP_GUARD, useClass: AuthGuard },\n {\n provide: AuthStartCheck,\n useFactory: (discovery: DiscoveryService): AuthStartCheck => new AuthStartCheck(discovery, options),\n inject: [DiscoveryService],\n },\n ],\n exports: [AUTH_SERVER_OPTIONS, AUTH_TOKEN_VERIFIER],\n };\n }\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import type { KeycloakTokenVerifier } from './token-verifier';
|
|
3
|
+
/**
|
|
4
|
+
* The only access check of an admin server. **Closed by default.**
|
|
5
|
+
*
|
|
6
|
+
* Two refusals, told apart by the question: no accepted token is «not signed in» (401) and is
|
|
7
|
+
* cured by signing in; a token without the right is «not allowed» (403) and is not. Neither names
|
|
8
|
+
* the missing right or what in the token did not match.
|
|
9
|
+
*/
|
|
10
|
+
export declare class AuthGuard implements CanActivate {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(verifier: KeycloakTokenVerifier);
|
|
13
|
+
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=auth.guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.guard.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/auth.guard.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAiE,MAAM,gBAAgB,CAAC;AAM9H,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAQ9D;;;;;;GAMG;AACH,qBACa,SAAU,YAAW,WAAW;;gBAGA,QAAQ,EAAE,qBAAqB;IAI3D,WAAW,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC;CAuBxE"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { __decorate, __metadata, __param } from "tslib";
|
|
2
|
+
import { ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
3
|
+
import { hasPermission } from '@rt-tools/auth-contract';
|
|
4
|
+
import { accessDeclarationsOf } from './access';
|
|
5
|
+
import { AUTH_TOKEN_VERIFIER, REQUEST_CALLER } from './auth.tokens';
|
|
6
|
+
/**
|
|
7
|
+
* The only access check of an admin server. **Closed by default.**
|
|
8
|
+
*
|
|
9
|
+
* Two refusals, told apart by the question: no accepted token is «not signed in» (401) and is
|
|
10
|
+
* cured by signing in; a token without the right is «not allowed» (403) and is not. Neither names
|
|
11
|
+
* the missing right or what in the token did not match.
|
|
12
|
+
*/
|
|
13
|
+
let AuthGuard = class AuthGuard {
|
|
14
|
+
#verifier;
|
|
15
|
+
constructor(verifier) {
|
|
16
|
+
this.#verifier = verifier;
|
|
17
|
+
}
|
|
18
|
+
async canActivate(context) {
|
|
19
|
+
const declared = accessDeclarationsOf(context.getHandler());
|
|
20
|
+
if (declared.length !== 1) {
|
|
21
|
+
// The start audit stops such an application; a handler that slipped past it is refused
|
|
22
|
+
// rather than opened by a guess.
|
|
23
|
+
throw new UnauthorizedException();
|
|
24
|
+
}
|
|
25
|
+
const access = declared[0];
|
|
26
|
+
if (access.kind === 'public') {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const request = context.switchToHttp().getRequest();
|
|
30
|
+
const header = request.headers['authorization'];
|
|
31
|
+
const caller = await this.#verifier.callerOf(typeof header === 'string' ? header : null);
|
|
32
|
+
if (!caller) {
|
|
33
|
+
throw new UnauthorizedException();
|
|
34
|
+
}
|
|
35
|
+
if (access.kind === 'permission' && !hasPermission(caller, access.permission)) {
|
|
36
|
+
throw new ForbiddenException();
|
|
37
|
+
}
|
|
38
|
+
request[REQUEST_CALLER] = caller;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
AuthGuard = __decorate([
|
|
43
|
+
Injectable(),
|
|
44
|
+
__param(0, Inject(AUTH_TOKEN_VERIFIER)),
|
|
45
|
+
__metadata("design:paramtypes", [Function])
|
|
46
|
+
], AuthGuard);
|
|
47
|
+
export { AuthGuard };
|
|
48
|
+
//# sourceMappingURL=auth.guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.guard.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/auth.guard.ts"],"names":[],"mappings":";AAAA,OAAO,EAAiC,kBAAkB,EAAE,MAAM,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAE9H,OAAO,EAAE,aAAa,EAAW,MAAM,yBAAyB,CAAC;AAEjE,OAAO,EAAE,oBAAoB,EAAW,MAAM,UAAU,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AASpE;;;;;;GAMG;AAEI,IAAM,SAAS,GAAf,MAAM,SAAS;IACT,SAAS,CAAwB;IAE1C,YAAyC,QAA+B;QACpE,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;IAC9B,CAAC;IAEM,KAAK,CAAC,WAAW,CAAC,OAAyB;QAC9C,MAAM,QAAQ,GAAuB,oBAAoB,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;QAChF,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,uFAAuF;YACvF,iCAAiC;YACjC,MAAM,IAAI,qBAAqB,EAAE,CAAC;QACtC,CAAC;QACD,MAAM,MAAM,GAAY,QAAQ,CAAC,CAAC,CAAC,CAAC;QACpC,IAAI,MAAM,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC3B,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,MAAM,OAAO,GAAiB,OAAO,CAAC,YAAY,EAAE,CAAC,UAAU,EAAgB,CAAC;QAChF,MAAM,MAAM,GAAkC,OAAO,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;QAC/E,MAAM,MAAM,GAAmB,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACzG,IAAI,CAAC,MAAM,EAAE,CAAC;YACV,MAAM,IAAI,qBAAqB,EAAE,CAAC;QACtC,CAAC;QACD,IAAI,MAAM,CAAC,IAAI,KAAK,YAAY,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;YAC5E,MAAM,IAAI,kBAAkB,EAAE,CAAC;QACnC,CAAC;QACD,OAAO,CAAC,cAAc,CAAC,GAAG,MAAM,CAAC;QACjC,OAAO,IAAI,CAAC;IAChB,CAAC;CACJ,CAAA;AA9BY,SAAS;IADrB,UAAU,EAAE;IAII,WAAA,MAAM,CAAC,mBAAmB,CAAC,CAAA;;GAH/B,SAAS,CA8BrB","sourcesContent":["import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';\n\nimport { hasPermission, ICaller } from '@rt-tools/auth-contract';\n\nimport { accessDeclarationsOf, TAccess } from './access';\nimport { AUTH_TOKEN_VERIFIER, REQUEST_CALLER } from './auth.tokens';\nimport type { KeycloakTokenVerifier } from './token-verifier';\n\n/** The request as the guard reads it: the headers and the place for the caller. */\ninterface IAuthRequest {\n headers: Record<string, string | string[] | undefined>;\n [REQUEST_CALLER]?: ICaller;\n}\n\n/**\n * The only access check of an admin server. **Closed by default.**\n *\n * Two refusals, told apart by the question: no accepted token is «not signed in» (401) and is\n * cured by signing in; a token without the right is «not allowed» (403) and is not. Neither names\n * the missing right or what in the token did not match.\n */\n@Injectable()\nexport class AuthGuard implements CanActivate {\n readonly #verifier: KeycloakTokenVerifier;\n\n constructor(@Inject(AUTH_TOKEN_VERIFIER) verifier: KeycloakTokenVerifier) {\n this.#verifier = verifier;\n }\n\n public async canActivate(context: ExecutionContext): Promise<boolean> {\n const declared: readonly TAccess[] = accessDeclarationsOf(context.getHandler());\n if (declared.length !== 1) {\n // The start audit stops such an application; a handler that slipped past it is refused\n // rather than opened by a guess.\n throw new UnauthorizedException();\n }\n const access: TAccess = declared[0];\n if (access.kind === 'public') {\n return true;\n }\n const request: IAuthRequest = context.switchToHttp().getRequest<IAuthRequest>();\n const header: string | string[] | undefined = request.headers['authorization'];\n const caller: ICaller | null = await this.#verifier.callerOf(typeof header === 'string' ? header : null);\n if (!caller) {\n throw new UnauthorizedException();\n }\n if (access.kind === 'permission' && !hasPermission(caller, access.permission)) {\n throw new ForbiddenException();\n }\n request[REQUEST_CALLER] = caller;\n return true;\n }\n}\n"]}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The token check of the admin, provided by the module. */
|
|
2
|
+
export declare const AUTH_TOKEN_VERIFIER: unique symbol;
|
|
3
|
+
/** The options the module was configured with. */
|
|
4
|
+
export declare const AUTH_SERVER_OPTIONS: unique symbol;
|
|
5
|
+
/** The field of the request the guard puts the caller into. */
|
|
6
|
+
export declare const REQUEST_CALLER: unique symbol;
|
|
7
|
+
//# sourceMappingURL=auth.tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.tokens.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/auth.tokens.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAC5D,eAAO,MAAM,mBAAmB,EAAE,OAAO,MAA+C,CAAC;AAEzF,kDAAkD;AAClD,eAAO,MAAM,mBAAmB,EAAE,OAAO,MAAwC,CAAC;AAElF,+DAA+D;AAC/D,eAAO,MAAM,cAAc,EAAE,OAAO,MAAuC,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The token check of the admin, provided by the module. */
|
|
2
|
+
export const AUTH_TOKEN_VERIFIER = Symbol('rt-tools.auth.token-verifier');
|
|
3
|
+
/** The options the module was configured with. */
|
|
4
|
+
export const AUTH_SERVER_OPTIONS = Symbol('rt-tools.auth.options');
|
|
5
|
+
/** The field of the request the guard puts the caller into. */
|
|
6
|
+
export const REQUEST_CALLER = Symbol('rt-tools.auth.caller');
|
|
7
|
+
//# sourceMappingURL=auth.tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.tokens.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/auth.tokens.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAC5D,MAAM,CAAC,MAAM,mBAAmB,GAAkB,MAAM,CAAC,8BAA8B,CAAC,CAAC;AAEzF,kDAAkD;AAClD,MAAM,CAAC,MAAM,mBAAmB,GAAkB,MAAM,CAAC,uBAAuB,CAAC,CAAC;AAElF,+DAA+D;AAC/D,MAAM,CAAC,MAAM,cAAc,GAAkB,MAAM,CAAC,sBAAsB,CAAC,CAAC","sourcesContent":["/** The token check of the admin, provided by the module. */\nexport const AUTH_TOKEN_VERIFIER: unique symbol = Symbol('rt-tools.auth.token-verifier');\n\n/** The options the module was configured with. */\nexport const AUTH_SERVER_OPTIONS: unique symbol = Symbol('rt-tools.auth.options');\n\n/** The field of the request the guard puts the caller into. */\nexport const REQUEST_CALLER: unique symbol = Symbol('rt-tools.auth.caller');\n"]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { TPermission } from '@rt-tools/auth-contract';
|
|
2
|
+
/** Where the catalog goes and with what it is let in. */
|
|
3
|
+
export interface ICatalogSyncOptions {
|
|
4
|
+
/** The Keycloak address, for example `https://sso.example.com`. */
|
|
5
|
+
readonly baseUrl: string;
|
|
6
|
+
readonly realm: string;
|
|
7
|
+
/** The client of this admin; its roles are its rights. */
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
/** A client of the realm with a service account allowed to manage clients. */
|
|
10
|
+
readonly syncClientId: string;
|
|
11
|
+
readonly syncClientSecret: string;
|
|
12
|
+
}
|
|
13
|
+
/** What the sync did: the roles it created and the roles Keycloak holds beyond the catalog. */
|
|
14
|
+
export interface ICatalogSyncResult {
|
|
15
|
+
readonly created: readonly string[];
|
|
16
|
+
readonly extra: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
type TFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
19
|
+
/**
|
|
20
|
+
* Sends the rights of an admin to Keycloak as roles of its client.
|
|
21
|
+
*
|
|
22
|
+
* Creates the roles Keycloak lacks and removes none: removing a role takes it from every person
|
|
23
|
+
* who holds it. The rights Keycloak holds beyond the catalog come back as `extra` for the log.
|
|
24
|
+
*/
|
|
25
|
+
export declare function syncPermissionCatalog(options: ICatalogSyncOptions, catalog: readonly TPermission[], request?: TFetch): Promise<ICatalogSyncResult>;
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=catalog-sync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-sync.d.ts","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/catalog-sync.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEpE,yDAAyD;AACzD,MAAM,WAAW,mBAAmB;IAChC,mEAAmE;IACnE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CACrC;AAED,+FAA+F;AAC/F,MAAM,WAAW,kBAAkB;IAC/B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,KAAK,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AASvE;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACvC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,SAAS,WAAW,EAAE,EAC/B,OAAO,GAAE,MAAc,GACxB,OAAO,CAAC,kBAAkB,CAAC,CA2C7B"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isPermission } from '@rt-tools/auth-contract';
|
|
2
|
+
async function answer(response, step) {
|
|
3
|
+
if (!response.ok) {
|
|
4
|
+
throw new Error(`The catalog sync stopped at ${step}: Keycloak answered ${response.status}`);
|
|
5
|
+
}
|
|
6
|
+
return (await response.json());
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Sends the rights of an admin to Keycloak as roles of its client.
|
|
10
|
+
*
|
|
11
|
+
* Creates the roles Keycloak lacks and removes none: removing a role takes it from every person
|
|
12
|
+
* who holds it. The rights Keycloak holds beyond the catalog come back as `extra` for the log.
|
|
13
|
+
*/
|
|
14
|
+
export async function syncPermissionCatalog(options, catalog, request = fetch) {
|
|
15
|
+
const base = `${options.baseUrl}/realms/${options.realm}`;
|
|
16
|
+
const admin = `${options.baseUrl}/admin/realms/${options.realm}`;
|
|
17
|
+
const token = await answer(await request(`${base}/protocol/openid-connect/token`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
20
|
+
body: new URLSearchParams({
|
|
21
|
+
grant_type: 'client_credentials',
|
|
22
|
+
client_id: options.syncClientId,
|
|
23
|
+
client_secret: options.syncClientSecret,
|
|
24
|
+
}),
|
|
25
|
+
}), 'the token');
|
|
26
|
+
const headers = { authorization: `Bearer ${token.access_token}`, 'content-type': 'application/json' };
|
|
27
|
+
const clients = await answer(await request(`${admin}/clients?clientId=${encodeURIComponent(options.clientId)}`, { headers }), 'the client');
|
|
28
|
+
if (clients.length !== 1) {
|
|
29
|
+
throw new Error(`The catalog sync stopped at the client: the realm has no client ${options.clientId}`);
|
|
30
|
+
}
|
|
31
|
+
const rolesUrl = `${admin}/clients/${clients[0].id}/roles`;
|
|
32
|
+
const held = new Set((await answer(await request(rolesUrl, { headers }), 'the roles')).map((role) => role.name));
|
|
33
|
+
const created = [];
|
|
34
|
+
for (const permission of catalog) {
|
|
35
|
+
if (held.has(permission)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const response = await request(rolesUrl, { method: 'POST', body: JSON.stringify({ name: permission }), headers });
|
|
39
|
+
if (!response.ok && response.status !== 409) {
|
|
40
|
+
throw new Error(`The catalog sync stopped at the role ${permission}: Keycloak answered ${response.status}`);
|
|
41
|
+
}
|
|
42
|
+
created.push(permission);
|
|
43
|
+
}
|
|
44
|
+
const wanted = new Set(catalog);
|
|
45
|
+
// Keycloak adds roles of its own to a client; only a role of the right shape counts as extra.
|
|
46
|
+
return { extra: [...held].filter((role) => isPermission(role) && !wanted.has(role)), created };
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=catalog-sync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-sync.js","sourceRoot":"","sources":["../../../../projects/auth-server/src/lib/catalog-sync.ts"],"names":[],"mappings":"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","sourcesContent":["import { isPermission, TPermission } from '@rt-tools/auth-contract';\n\n/** Where the catalog goes and with what it is let in. */\nexport interface ICatalogSyncOptions {\n /** The Keycloak address, for example `https://sso.example.com`. */\n readonly baseUrl: string;\n readonly realm: string;\n /** The client of this admin; its roles are its rights. */\n readonly clientId: string;\n /** A client of the realm with a service account allowed to manage clients. */\n readonly syncClientId: string;\n readonly syncClientSecret: string;\n}\n\n/** What the sync did: the roles it created and the roles Keycloak holds beyond the catalog. */\nexport interface ICatalogSyncResult {\n readonly created: readonly string[];\n readonly extra: readonly string[];\n}\n\ntype TFetch = (input: string, init?: RequestInit) => Promise<Response>;\n\nasync function answer<T>(response: Response, step: string): Promise<T> {\n if (!response.ok) {\n throw new Error(`The catalog sync stopped at ${step}: Keycloak answered ${response.status}`);\n }\n return (await response.json()) as T;\n}\n\n/**\n * Sends the rights of an admin to Keycloak as roles of its client.\n *\n * Creates the roles Keycloak lacks and removes none: removing a role takes it from every person\n * who holds it. The rights Keycloak holds beyond the catalog come back as `extra` for the log.\n */\nexport async function syncPermissionCatalog(\n options: ICatalogSyncOptions,\n catalog: readonly TPermission[],\n request: TFetch = fetch\n): Promise<ICatalogSyncResult> {\n const base: string = `${options.baseUrl}/realms/${options.realm}`;\n const admin: string = `${options.baseUrl}/admin/realms/${options.realm}`;\n const token: { access_token: string } = await answer(\n await request(`${base}/protocol/openid-connect/token`, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'client_credentials',\n client_id: options.syncClientId,\n client_secret: options.syncClientSecret,\n }),\n }),\n 'the token'\n );\n const headers: Record<string, string> = { authorization: `Bearer ${token.access_token}`, 'content-type': 'application/json' };\n const clients: { id: string }[] = await answer(\n await request(`${admin}/clients?clientId=${encodeURIComponent(options.clientId)}`, { headers }),\n 'the client'\n );\n if (clients.length !== 1) {\n throw new Error(`The catalog sync stopped at the client: the realm has no client ${options.clientId}`);\n }\n const rolesUrl: string = `${admin}/clients/${clients[0].id}/roles`;\n const held: Set<string> = new Set(\n (await answer<{ name: string }[]>(await request(rolesUrl, { headers }), 'the roles')).map(\n (role: { name: string }): string => role.name\n )\n );\n const created: string[] = [];\n for (const permission of catalog) {\n if (held.has(permission)) {\n continue;\n }\n const response: Response = await request(rolesUrl, { method: 'POST', body: JSON.stringify({ name: permission }), headers });\n if (!response.ok && response.status !== 409) {\n throw new Error(`The catalog sync stopped at the role ${permission}: Keycloak answered ${response.status}`);\n }\n created.push(permission);\n }\n const wanted: Set<string> = new Set<string>(catalog);\n // Keycloak adds roles of its own to a client; only a role of the right shape counts as extra.\n return { extra: [...held].filter((role: string): boolean => isPermission(role) && !wanted.has(role)), created };\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ContextKey, Interceptor } from '@connectrpc/connect';
|
|
2
|
+
import { ICaller } from '@rt-tools/auth-contract';
|
|
3
|
+
import { TAccess } from './access';
|
|
4
|
+
import { KeycloakTokenVerifier } from './token-verifier';
|
|
5
|
+
/** The part of a Connect service description the access map is checked against. */
|
|
6
|
+
export interface IConnectServiceLike {
|
|
7
|
+
readonly typeName: string;
|
|
8
|
+
readonly methods: readonly {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly localName: string;
|
|
11
|
+
}[];
|
|
12
|
+
}
|
|
13
|
+
/** The access of every method of one service, keyed by the local method name. */
|
|
14
|
+
export type TConnectServiceAccess<SERVICE extends {
|
|
15
|
+
readonly method: object;
|
|
16
|
+
}> = {
|
|
17
|
+
readonly [METHOD in keyof SERVICE['method']]: TAccess;
|
|
18
|
+
};
|
|
19
|
+
/** The caller the interceptor accepted, read by a procedure from its context values. */
|
|
20
|
+
export declare const CONNECT_CALLER: ContextKey<ICaller | null>;
|
|
21
|
+
/**
|
|
22
|
+
* The accesses of one service as entries `typeName/MethodName`, checked against its description.
|
|
23
|
+
*
|
|
24
|
+
* Every method declares exactly one access, and a key that names no method is refused too: both
|
|
25
|
+
* would otherwise show only to the person who calls the method.
|
|
26
|
+
*/
|
|
27
|
+
export declare function connectAccessEntries(service: IConnectServiceLike, access: Readonly<Record<string, TAccess>>): readonly [string, TAccess][];
|
|
28
|
+
/**
|
|
29
|
+
* The Connect interceptor of an admin server: the same token check as the NestJS guard, and the
|
|
30
|
+
* same two refusals — `unauthenticated` and `permission_denied` — without naming the right.
|
|
31
|
+
*
|
|
32
|
+
* A method the map does not name is refused: the map is closed by default, as the guard is.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createAuthInterceptor(verifier: KeycloakTokenVerifier, entries: readonly (readonly [string, TAccess])[]): Interceptor;
|
|
35
|
+
//# sourceMappingURL=connect-access.d.ts.map
|