@adatechnology/auth-keycloak 0.1.0 → 0.1.1

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/dist/index.d.ts CHANGED
@@ -137,6 +137,23 @@ type RolesOptions = {
137
137
  */
138
138
  declare function Roles(...args: Array<string | string[] | RolesOptions>): _nestjs_common.CustomDecorator<string>;
139
139
 
140
+ /**
141
+ * Guard that checks whether the current request has the required roles.
142
+ *
143
+ * Supports two auth paths transparently:
144
+ *
145
+ * 1. **Kong path** (user-facing, preferred):
146
+ * Kong validates the token, removes Authorization, and injects:
147
+ * - `X-User-Id` — keycloak sub
148
+ * - `X-User-Roles` — comma-separated realm roles
149
+ * The guard reads roles from `X-User-Roles` header.
150
+ *
151
+ * 2. **B2B path** (service-to-service, e.g. BFF → API):
152
+ * The caller sends a service account JWT in the Authorization header.
153
+ * The guard decodes the JWT locally and reads `realm_access.roles`.
154
+ *
155
+ * Priority: Kong header → JWT fallback.
156
+ */
140
157
  declare class RolesGuard implements CanActivate {
141
158
  private readonly reflector;
142
159
  private readonly config?;
@@ -145,6 +162,104 @@ declare class RolesGuard implements CanActivate {
145
162
  private decodeJwtPayload;
146
163
  }
147
164
 
165
+ /**
166
+ * B2B Guard — service-to-service routes (e.g. BFF → API, Worker → API).
167
+ *
168
+ * The caller must send a valid service account token (client_credentials)
169
+ * in the Authorization header. Delegates full token validation to
170
+ * BearerTokenGuard (Keycloak introspection).
171
+ *
172
+ * The caller is also expected to forward X-User-Id so the API knows which
173
+ * end-user context the call belongs to.
174
+ *
175
+ * Use for routes that are ONLY called by internal services, not by end users.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * @Post('internal/notify')
180
+ * @Roles('send-notifications')
181
+ * @UseGuards(B2BGuard, RolesGuard)
182
+ * async notify(@AuthUser() keycloakId: string) { ... }
183
+ * ```
184
+ */
185
+ declare class B2BGuard implements CanActivate {
186
+ private readonly bearerTokenGuard;
187
+ constructor(bearerTokenGuard: BearerTokenGuard);
188
+ canActivate(context: ExecutionContext): Promise<boolean>;
189
+ }
190
+
191
+ /**
192
+ * B2C Guard — user-facing routes via Kong.
193
+ *
194
+ * Kong validated the JWT (JWKS local), removed the Authorization header,
195
+ * and injected X-User-Id + X-User-Roles. This guard simply asserts those
196
+ * headers are present — it does NOT re-validate any token.
197
+ *
198
+ * Use for routes that are ONLY called by end users through Kong.
199
+ *
200
+ * @example
201
+ * ```ts
202
+ * @Get('me')
203
+ * @Roles('user-manager')
204
+ * @UseGuards(B2CGuard, RolesGuard)
205
+ * async getMe(@AuthUser() keycloakId: string) { ... }
206
+ * ```
207
+ */
208
+ declare class B2CGuard implements CanActivate {
209
+ canActivate(context: ExecutionContext): boolean;
210
+ }
211
+
212
+ /**
213
+ * ApiAuthGuard — composite guard for routes that accept both paths.
214
+ *
215
+ * Detects which path the request is coming from and delegates accordingly:
216
+ *
217
+ * - **B2B path** (Authorization header present):
218
+ * Service-to-service call (e.g. BFF → API). Delegates to B2BGuard,
219
+ * which validates the service account token via BearerTokenGuard.
220
+ *
221
+ * - **B2C path** (X-User-Id header present, no Authorization):
222
+ * User call routed by Kong. Delegates to B2CGuard,
223
+ * which asserts Kong identity headers are present.
224
+ *
225
+ * Use when the same route must be reachable both from Kong (users) and
226
+ * from internal services (BFF, Worker). If the route is exclusive to one
227
+ * path, prefer B2BGuard or B2CGuard directly for explicit intent.
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * @Get('me')
232
+ * @Roles('user-manager')
233
+ * @UseGuards(ApiAuthGuard, RolesGuard)
234
+ * async getMe(@AuthUser() keycloakId: string) { ... }
235
+ * ```
236
+ */
237
+ declare class ApiAuthGuard implements CanActivate {
238
+ private readonly b2bGuard;
239
+ private readonly b2cGuard;
240
+ constructor(b2bGuard: B2BGuard, b2cGuard: B2CGuard);
241
+ canActivate(context: ExecutionContext): Promise<boolean>;
242
+ }
243
+
244
+ /**
245
+ * Parameter decorator that extracts the authenticated user's Keycloak ID
246
+ * from the `X-User-Id` header injected by Kong after token validation.
247
+ *
248
+ * In the B2B path (service-to-service), the caller is responsible for
249
+ * forwarding the same header.
250
+ *
251
+ * @example
252
+ * ```ts
253
+ * @Get('me')
254
+ * @Roles('user-manager')
255
+ * @UseGuards(RolesGuard)
256
+ * async getMe(@AuthUser() keycloakId: string) {
257
+ * return this.userService.getUserByKeycloakId(keycloakId);
258
+ * }
259
+ * ```
260
+ */
261
+ declare const AuthUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
262
+
148
263
  declare class KeycloakError extends Error {
149
264
  readonly statusCode?: number;
150
265
  readonly details?: unknown;
@@ -156,4 +271,4 @@ declare class KeycloakError extends Error {
156
271
  });
157
272
  }
158
273
 
159
- export { BearerTokenGuard, KEYCLOAK_CLIENT, KEYCLOAK_CONFIG, KEYCLOAK_HTTP_INTERCEPTOR, KEYCLOAK_PROVIDER, type KeycloakClientInterface, type KeycloakConfig, KeycloakError, KeycloakModule, type KeycloakProviderInterface, type KeycloakTokenResponse, Roles, RolesGuard };
274
+ export { ApiAuthGuard, AuthUser, B2BGuard, B2CGuard, BearerTokenGuard, KEYCLOAK_CLIENT, KEYCLOAK_CONFIG, KEYCLOAK_HTTP_INTERCEPTOR, KEYCLOAK_PROVIDER, type KeycloakClientInterface, type KeycloakConfig, KeycloakError, KeycloakModule, type KeycloakProviderInterface, type KeycloakTokenResponse, Roles, RolesGuard };
package/dist/index.js CHANGED
@@ -68,7 +68,7 @@ var require_base_app_error = __commonJS({
68
68
  "use strict";
69
69
  Object.defineProperty(exports2, "__esModule", { value: true });
70
70
  exports2.BaseAppError = void 0;
71
- var BaseAppError3 = class extends Error {
71
+ var BaseAppError5 = class extends Error {
72
72
  code;
73
73
  status;
74
74
  context;
@@ -83,7 +83,7 @@ var require_base_app_error = __commonJS({
83
83
  (_a = capturable.captureStackTrace) == null ? void 0 : _a.call(capturable, this, this.constructor);
84
84
  }
85
85
  };
86
- exports2.BaseAppError = BaseAppError3;
86
+ exports2.BaseAppError = BaseAppError5;
87
87
  }
88
88
  });
89
89
 
@@ -268,6 +268,10 @@ var require_dist = __commonJS({
268
268
  // src/index.ts
269
269
  var index_exports = {};
270
270
  __export(index_exports, {
271
+ ApiAuthGuard: () => ApiAuthGuard,
272
+ AuthUser: () => AuthUser,
273
+ B2BGuard: () => B2BGuard,
274
+ B2CGuard: () => B2CGuard,
271
275
  BearerTokenGuard: () => BearerTokenGuard,
272
276
  KEYCLOAK_CLIENT: () => KEYCLOAK_CLIENT,
273
277
  KEYCLOAK_CONFIG: () => KEYCLOAK_CONFIG,
@@ -302,7 +306,7 @@ var KEYCLOAK_PROVIDER = "KEYCLOAK_PROVIDER";
302
306
  // package.json
303
307
  var package_default = {
304
308
  name: "@adatechnology/auth-keycloak",
305
- version: "0.1.0",
309
+ version: "0.1.1",
306
310
  publishConfig: {
307
311
  access: "public"
308
312
  },
@@ -776,49 +780,54 @@ var RolesGuard = class {
776
780
  this.config = config;
777
781
  }
778
782
  canActivate(context) {
779
- var _a, _b, _c, _d, _e, _f, _g, _h;
783
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j;
780
784
  const meta = this.reflector.get(ROLES_META_KEY, context.getHandler()) || this.reflector.get(ROLES_META_KEY, context.getClass());
781
785
  if (!meta || !meta.roles || meta.roles.length === 0) return true;
782
786
  const req = context.switchToHttp().getRequest();
783
- const authHeader = ((_a = req.headers) == null ? void 0 : _a.authorization) || ((_b = req.headers) == null ? void 0 : _b.Authorization);
784
- const token = authHeader ? String(authHeader).split(" ")[1] : (_c = req.query) == null ? void 0 : _c.token;
785
- if (!token)
786
- throw new import_shared2.BaseAppError({
787
- message: "Authorization token not provided",
788
- status: HTTP_STATUS.FORBIDDEN,
789
- code: ROLES_ERROR_CODE.MISSING_TOKEN,
790
- context: {}
791
- });
792
- const payload = this.decodeJwtPayload(token);
787
+ const required = meta.roles;
793
788
  const availableRoles = /* @__PURE__ */ new Set();
794
- if (((_d = payload == null ? void 0 : payload.realm_access) == null ? void 0 : _d.roles) && Array.isArray(payload.realm_access.roles)) {
795
- payload.realm_access.roles.forEach((r) => availableRoles.add(r));
796
- }
797
- const clientId = (_f = (_e = this.config) == null ? void 0 : _e.credentials) == null ? void 0 : _f.clientId;
798
- if (clientId && ((_h = (_g = payload == null ? void 0 : payload.resource_access) == null ? void 0 : _g[clientId]) == null ? void 0 : _h.roles)) {
799
- payload.resource_access[clientId].roles.forEach(
800
- (r) => availableRoles.add(r)
801
- );
802
- }
803
- if (meta.type === "both" && (payload == null ? void 0 : payload.resource_access)) {
804
- Object.values(payload.resource_access).forEach((entry) => {
805
- if ((entry == null ? void 0 : entry.roles) && Array.isArray(entry.roles)) {
806
- entry.roles.forEach(
807
- (r) => availableRoles.add(r)
808
- );
809
- }
810
- });
789
+ const kongRolesHeader = ((_a = req.headers) == null ? void 0 : _a["x-user-roles"]) || ((_b = req.headers) == null ? void 0 : _b["X-User-Roles"]);
790
+ if (kongRolesHeader) {
791
+ kongRolesHeader.split(",").map((r) => r.trim()).filter(Boolean).forEach((r) => availableRoles.add(r));
792
+ } else {
793
+ const authHeader = ((_c = req.headers) == null ? void 0 : _c.authorization) || ((_d = req.headers) == null ? void 0 : _d.Authorization);
794
+ const token = authHeader ? String(authHeader).split(" ")[1] : (_e = req.query) == null ? void 0 : _e.token;
795
+ if (!token) {
796
+ throw new import_shared2.BaseAppError({
797
+ message: "Authorization token not provided",
798
+ status: HTTP_STATUS.FORBIDDEN,
799
+ code: ROLES_ERROR_CODE.MISSING_TOKEN,
800
+ context: {}
801
+ });
802
+ }
803
+ const payload = this.decodeJwtPayload(token);
804
+ if (((_f = payload == null ? void 0 : payload.realm_access) == null ? void 0 : _f.roles) && Array.isArray(payload.realm_access.roles)) {
805
+ payload.realm_access.roles.forEach((r) => availableRoles.add(r));
806
+ }
807
+ const clientId = (_h = (_g = this.config) == null ? void 0 : _g.credentials) == null ? void 0 : _h.clientId;
808
+ if (clientId && ((_j = (_i = payload == null ? void 0 : payload.resource_access) == null ? void 0 : _i[clientId]) == null ? void 0 : _j.roles)) {
809
+ payload.resource_access[clientId].roles.forEach(
810
+ (r) => availableRoles.add(r)
811
+ );
812
+ }
813
+ if (meta.type === "both" && (payload == null ? void 0 : payload.resource_access)) {
814
+ Object.values(payload.resource_access).forEach((entry) => {
815
+ if ((entry == null ? void 0 : entry.roles) && Array.isArray(entry.roles)) {
816
+ entry.roles.forEach((r) => availableRoles.add(r));
817
+ }
818
+ });
819
+ }
811
820
  }
812
- const required = meta.roles || [];
813
821
  const hasMatch = required.map((r) => availableRoles.has(r));
814
822
  const result = meta.mode === "all" ? hasMatch.every(Boolean) : hasMatch.some(Boolean);
815
- if (!result)
823
+ if (!result) {
816
824
  throw new import_shared2.BaseAppError({
817
825
  message: "Insufficient roles",
818
826
  status: HTTP_STATUS.FORBIDDEN,
819
827
  code: ROLES_ERROR_CODE.INSUFFICIENT_ROLES,
820
828
  context: { required }
821
829
  });
830
+ }
822
831
  return true;
823
832
  }
824
833
  decodeJwtPayload(token) {
@@ -830,7 +839,7 @@ var RolesGuard = class {
830
839
  if (!BufferCtor) return {};
831
840
  const decoded = BufferCtor.from(payload, "base64").toString("utf8");
832
841
  return JSON.parse(decoded);
833
- } catch (e) {
842
+ } catch {
834
843
  return {};
835
844
  }
836
845
  }
@@ -900,8 +909,89 @@ var KeycloakModule = class {
900
909
  KeycloakModule = __decorateClass([
901
910
  (0, import_common6.Module)({})
902
911
  ], KeycloakModule);
912
+
913
+ // src/b2b.guard.ts
914
+ var import_common7 = require("@nestjs/common");
915
+ var B2BGuard = class {
916
+ constructor(bearerTokenGuard) {
917
+ this.bearerTokenGuard = bearerTokenGuard;
918
+ }
919
+ canActivate(context) {
920
+ return Promise.resolve(this.bearerTokenGuard.canActivate(context));
921
+ }
922
+ };
923
+ B2BGuard = __decorateClass([
924
+ (0, import_common7.Injectable)()
925
+ ], B2BGuard);
926
+
927
+ // src/b2c.guard.ts
928
+ var import_common8 = require("@nestjs/common");
929
+ var import_shared3 = __toESM(require_dist());
930
+ var B2CGuard = class {
931
+ canActivate(context) {
932
+ var _a, _b;
933
+ const request = context.switchToHttp().getRequest();
934
+ const userId = ((_a = request.headers) == null ? void 0 : _a["x-user-id"]) ?? ((_b = request.headers) == null ? void 0 : _b["X-User-Id"]);
935
+ if (userId) return true;
936
+ throw new import_shared3.BaseAppError({
937
+ message: "Missing Kong identity headers (X-User-Id). Route requires user authentication via Kong.",
938
+ status: HTTP_STATUS.UNAUTHORIZED,
939
+ code: BEARER_ERROR_CODE.MISSING_TOKEN,
940
+ context: {}
941
+ });
942
+ }
943
+ };
944
+ B2CGuard = __decorateClass([
945
+ (0, import_common8.Injectable)()
946
+ ], B2CGuard);
947
+
948
+ // src/api-auth.guard.ts
949
+ var import_common9 = require("@nestjs/common");
950
+ var import_shared4 = __toESM(require_dist());
951
+ var ApiAuthGuard = class {
952
+ constructor(b2bGuard, b2cGuard) {
953
+ this.b2bGuard = b2bGuard;
954
+ this.b2cGuard = b2cGuard;
955
+ }
956
+ async canActivate(context) {
957
+ var _a, _b, _c, _d;
958
+ const request = context.switchToHttp().getRequest();
959
+ const authHeader = ((_a = request.headers) == null ? void 0 : _a.authorization) ?? ((_b = request.headers) == null ? void 0 : _b.Authorization);
960
+ if (authHeader == null ? void 0 : authHeader.toLowerCase().startsWith("bearer ")) {
961
+ return this.b2bGuard.canActivate(context);
962
+ }
963
+ const userId = ((_c = request.headers) == null ? void 0 : _c["x-user-id"]) ?? ((_d = request.headers) == null ? void 0 : _d["X-User-Id"]);
964
+ if (userId) {
965
+ return this.b2cGuard.canActivate(context);
966
+ }
967
+ throw new import_shared4.BaseAppError({
968
+ message: "Unauthorized: missing Authorization header (B2B) or Kong identity headers (B2C)",
969
+ status: HTTP_STATUS.UNAUTHORIZED,
970
+ code: BEARER_ERROR_CODE.MISSING_TOKEN,
971
+ context: {}
972
+ });
973
+ }
974
+ };
975
+ ApiAuthGuard = __decorateClass([
976
+ (0, import_common9.Injectable)()
977
+ ], ApiAuthGuard);
978
+
979
+ // src/auth-user.decorator.ts
980
+ var import_common10 = require("@nestjs/common");
981
+ var AuthUser = (0, import_common10.createParamDecorator)(
982
+ (_data, ctx) => {
983
+ var _a, _b;
984
+ const request = ctx.switchToHttp().getRequest();
985
+ const raw = ((_a = request.headers) == null ? void 0 : _a["x-user-id"]) ?? ((_b = request.headers) == null ? void 0 : _b["X-User-Id"]);
986
+ return Array.isArray(raw) ? raw[0] : raw ?? "";
987
+ }
988
+ );
903
989
  // Annotate the CommonJS export names for ESM import in node:
904
990
  0 && (module.exports = {
991
+ ApiAuthGuard,
992
+ AuthUser,
993
+ B2BGuard,
994
+ B2CGuard,
905
995
  BearerTokenGuard,
906
996
  KEYCLOAK_CLIENT,
907
997
  KEYCLOAK_CONFIG,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/auth-keycloak",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },