@hed-hog/core 0.0.373 → 0.0.374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +83 -1
  2. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  3. package/dist/integration-profile/integration-profile.secrets.js +4 -0
  4. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  5. package/dist/integration-profile/integration-profile.utils.d.ts +6 -0
  6. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  7. package/dist/integration-profile/integration-profile.utils.js +1 -1
  8. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  9. package/dist/oauth/oauth.controller.d.ts +11 -3
  10. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  11. package/dist/oauth/oauth.controller.js +52 -14
  12. package/dist/oauth/oauth.controller.js.map +1 -1
  13. package/dist/oauth/oauth.module.d.ts.map +1 -1
  14. package/dist/oauth/oauth.module.js +4 -0
  15. package/dist/oauth/oauth.module.js.map +1 -1
  16. package/dist/oauth/oauth.service.d.ts +38 -2
  17. package/dist/oauth/oauth.service.d.ts.map +1 -1
  18. package/dist/oauth/oauth.service.js +122 -5
  19. package/dist/oauth/oauth.service.js.map +1 -1
  20. package/dist/oauth/providers/apple.provider.d.ts +34 -0
  21. package/dist/oauth/providers/apple.provider.d.ts.map +1 -0
  22. package/dist/oauth/providers/apple.provider.js +197 -0
  23. package/dist/oauth/providers/apple.provider.js.map +1 -0
  24. package/dist/oauth/providers/facebook.provider.d.ts +1 -1
  25. package/dist/oauth/providers/facebook.provider.d.ts.map +1 -1
  26. package/dist/oauth/providers/facebook.provider.js +3 -2
  27. package/dist/oauth/providers/facebook.provider.js.map +1 -1
  28. package/dist/oauth/providers/google.provider.d.ts +1 -1
  29. package/dist/oauth/providers/google.provider.d.ts.map +1 -1
  30. package/dist/oauth/providers/google.provider.js +3 -2
  31. package/dist/oauth/providers/google.provider.js.map +1 -1
  32. package/dist/oauth/providers/linkedin.provider.d.ts +25 -0
  33. package/dist/oauth/providers/linkedin.provider.d.ts.map +1 -0
  34. package/dist/oauth/providers/linkedin.provider.js +99 -0
  35. package/dist/oauth/providers/linkedin.provider.js.map +1 -0
  36. package/dist/oauth/providers/microsoft-entra-id.provider.js +1 -1
  37. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  38. package/dist/oauth/providers/microsoft.provider.d.ts +1 -1
  39. package/dist/oauth/providers/microsoft.provider.d.ts.map +1 -1
  40. package/dist/oauth/providers/microsoft.provider.js +3 -2
  41. package/dist/oauth/providers/microsoft.provider.js.map +1 -1
  42. package/dist/pdf/pdf.service.d.ts.map +1 -1
  43. package/dist/pdf/pdf.service.js +19 -2
  44. package/dist/pdf/pdf.service.js.map +1 -1
  45. package/dist/setting/setting.service.d.ts.map +1 -1
  46. package/dist/setting/setting.service.js +4 -1
  47. package/dist/setting/setting.service.js.map +1 -1
  48. package/hedhog/data/integration_provider.yaml +16 -0
  49. package/hedhog/data/setting_group.yaml +98 -0
  50. package/hedhog/data/setting_subgroup.yaml +16 -0
  51. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +2 -0
  52. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +11 -11
  53. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +42 -0
  54. package/hedhog/frontend/app/menu/page.tsx.ejs +1 -1
  55. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +8 -0
  56. package/hedhog/frontend/messages/en.json +35 -0
  57. package/hedhog/frontend/messages/pt.json +35 -0
  58. package/hedhog/table/user_account.yaml +1 -0
  59. package/package.json +3 -3
  60. package/src/access-log/access-log.service.spec.ts +235 -0
  61. package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
  62. package/src/auth/auth.service.spec.ts +891 -0
  63. package/src/auth/guards/auth.guard.spec.ts +177 -0
  64. package/src/challenge/challenge.service.spec.ts +334 -0
  65. package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
  66. package/src/file/image-optimization.service.spec.ts +283 -0
  67. package/src/file/provider/provider.factory.spec.ts +111 -0
  68. package/src/install/install.service.spec.ts +139 -0
  69. package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
  70. package/src/integration/services/inbox.service.spec.ts +183 -0
  71. package/src/integration/services/integration-link.service.spec.ts +217 -0
  72. package/src/integration/services/outbox.service.spec.ts +290 -0
  73. package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
  74. package/src/integration-profile/integration-profile.secrets.ts +4 -0
  75. package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
  76. package/src/integration-profile/integration-profile.utils.ts +14 -1
  77. package/src/locale/locale.service.spec.ts +78 -0
  78. package/src/mail/mail.service.spec.ts +257 -0
  79. package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
  80. package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
  81. package/src/mcp/mcp-api-key.service.spec.ts +125 -0
  82. package/src/mcp/mcp-registry.service.spec.ts +96 -0
  83. package/src/mcp/mcp-server.service.spec.ts +187 -0
  84. package/src/menu/menu.service.spec.ts +76 -0
  85. package/src/notification/notification.service.spec.ts +297 -0
  86. package/src/oauth/oauth.controller.ts +53 -10
  87. package/src/oauth/oauth.module.ts +4 -0
  88. package/src/oauth/oauth.service.spec.ts +332 -0
  89. package/src/oauth/oauth.service.ts +156 -1
  90. package/src/oauth/providers/apple.provider.ts +175 -0
  91. package/src/oauth/providers/facebook.provider.ts +3 -2
  92. package/src/oauth/providers/google.provider.ts +3 -2
  93. package/src/oauth/providers/linkedin.provider.ts +96 -0
  94. package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
  95. package/src/oauth/providers/microsoft.provider.ts +3 -2
  96. package/src/pdf/pdf.service.ts +20 -2
  97. package/src/role/guards/role.guard.spec.ts +157 -0
  98. package/src/role/role.service.spec.ts +179 -0
  99. package/src/session/session.service.spec.ts +376 -0
  100. package/src/setting/setting.service.spec.ts +316 -0
  101. package/src/setting/setting.service.ts +4 -1
  102. package/src/task/task.service.spec.ts +47 -0
  103. package/src/token/token.service.spec.ts +351 -0
  104. package/src/user/user.service.spec.ts +256 -0
  105. package/src/utils/locale-context.spec.ts +22 -0
  106. package/src/utils/webauthn-origin.spec.ts +73 -0
  107. package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
@@ -25,9 +25,11 @@ import {
25
25
  OAuthCallbackInProgressError,
26
26
  OAuthProviderException,
27
27
  } from './oauth.errors';
28
+ import { AppleProvider } from './providers/apple.provider';
28
29
  import { FacebookProvider } from './providers/facebook.provider';
29
30
  import { GithubProvider } from './providers/github.provider';
30
31
  import { GoogleProvider } from './providers/google.provider';
32
+ import { LinkedinProvider } from './providers/linkedin.provider';
31
33
  import { MicrosoftEntraIdProvider } from './providers/microsoft-entra-id.provider';
32
34
  import { MicrosoftProvider } from './providers/microsoft.provider';
33
35
 
@@ -44,6 +46,10 @@ type HandleCallbackProps = {
44
46
  redirectUri?: string;
45
47
  state?: string;
46
48
  includeRefreshTokenInBody?: boolean;
49
+ /** Origin header of the request that hit the callback (used to bind web hub flows). */
50
+ requestOrigin?: string;
51
+ /** Referer header, used as a fallback when the Origin header is absent. */
52
+ requestReferer?: string;
47
53
  };
48
54
 
49
55
  @Injectable()
@@ -51,6 +57,8 @@ export class OAuthService {
51
57
  private readonly logger = new Logger(OAuthService.name);
52
58
  private providers: Map<string, OAuthProvider> = new Map();
53
59
  private readonly mobileOAuthStateTtlMs = 10 * 60 * 1000;
60
+ /** Sentinel app in the web state meaning "the OAuth hub handles this locally". */
61
+ private static readonly WEB_STATE_SELF_APP = 'self';
54
62
 
55
63
  constructor(
56
64
  google: GoogleProvider,
@@ -58,6 +66,8 @@ export class OAuthService {
58
66
  microsoft: MicrosoftProvider,
59
67
  github: GithubProvider,
60
68
  microsoftEntraId: MicrosoftEntraIdProvider,
69
+ apple: AppleProvider,
70
+ linkedin: LinkedinProvider,
61
71
  private readonly callbackCoordinator: OAuthCallbackCoordinatorService,
62
72
  @Inject(forwardRef(() => AuthService))
63
73
  private readonly auth: AuthService,
@@ -75,6 +85,8 @@ export class OAuthService {
75
85
  this.providers.set(microsoft.getProviderType(), microsoft);
76
86
  this.providers.set(github.getProviderType(), github);
77
87
  this.providers.set(microsoftEntraId.getProviderType(), microsoftEntraId);
88
+ this.providers.set(apple.getProviderType(), apple);
89
+ this.providers.set(linkedin.getProviderType(), linkedin);
78
90
  }
79
91
 
80
92
  async getAuthUrl(provider: user_account_provider_52222e2ecb_enum, callbackPath: string) {
@@ -94,7 +106,9 @@ export class OAuthService {
94
106
  throw new BadRequestException('Invalid mobile redirect URI.');
95
107
  }
96
108
 
97
- const callbackPath = `/callback/${String(provider)}/login`;
109
+ // Flow-less callback to match the single registered redirect URI and the
110
+ // flow-less token exchange in each provider's getProfile.
111
+ const callbackPath = `/callback/${String(provider)}`;
98
112
  const authUrl = await this.getAuthUrl(provider, callbackPath);
99
113
  const flow = 'login';
100
114
  const { token, signature } = await this.issueMobileState(
@@ -108,6 +122,114 @@ export class OAuthService {
108
122
  return url.toString();
109
123
  }
110
124
 
125
+ /**
126
+ * Builds a provider auth URL for a browser (web) flow that may be initiated by an
127
+ * app other than the OAuth hub (the app configured in the `url` setting).
128
+ *
129
+ * When `redirectApp` resolves to a known origin (via the `app-urls` setting), a
130
+ * signed `state` (`hhweb.<app>.<flow>.<sig>`) is attached so the hub can forward
131
+ * the callback back to the initiating app and the backend can bind the code
132
+ * exchange to that origin. Without a resolvable `redirectApp`, the URL is returned
133
+ * unchanged — identical to the legacy hub-only behavior.
134
+ */
135
+ async getWebAuthUrl(
136
+ provider: user_account_provider_52222e2ecb_enum,
137
+ flow: 'login' | 'register' | 'connect',
138
+ redirectApp?: string,
139
+ ) {
140
+ // Single, flow-less callback per provider: the flow (and the initiating app)
141
+ // travel in the signed state, so only ONE redirect URI needs to be registered
142
+ // per provider in its OAuth console.
143
+ const callbackPath = `/callback/${String(provider)}`;
144
+ const authUrl = await this.getAuthUrl(provider, callbackPath);
145
+
146
+ // Always carry the flow. `self` means "the hub handles it locally"; a resolvable
147
+ // app key means the hub forwards the callback back to that app (origin-bound).
148
+ const app = this.sanitizeAppKey(redirectApp) ?? OAuthService.WEB_STATE_SELF_APP;
149
+
150
+ const url = new URL(authUrl);
151
+ url.searchParams.set('state', this.buildWebState(app, flow));
152
+ return url.toString();
153
+ }
154
+
155
+ /**
156
+ * Resolves how the GitHub backend bounce should forward the browser. GitHub only
157
+ * supports a single registered callback URL (the API), so the provider always
158
+ * lands on the backend; this maps the signed web state to the initiating app's
159
+ * origin. Returns null for legacy hub-only flows (state carries just the flow).
160
+ */
161
+ async resolveWebForward(
162
+ state?: string,
163
+ ): Promise<{ origin: string | null; flow: 'login' | 'register' | 'connect' } | null> {
164
+ const web = this.parseWebState(state);
165
+ if (!web) {
166
+ return null;
167
+ }
168
+ return { origin: await this.resolveAppOrigin(web.app), flow: web.flow };
169
+ }
170
+
171
+ private sanitizeAppKey(app?: string): string | null {
172
+ const value = String(app ?? '').trim().toLowerCase();
173
+ return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value) ? value : null;
174
+ }
175
+
176
+ private buildWebState(app: string, flow: 'login' | 'register' | 'connect') {
177
+ const signature = this.security.signHmacSha256(`oauth-web-state:${app}:${flow}`);
178
+ return `hhweb.${app}.${flow}.${signature}`;
179
+ }
180
+
181
+ /** Parses and verifies a signed web-hub state. Returns null when invalid. */
182
+ private parseWebState(
183
+ state?: string,
184
+ ): { app: string; flow: 'login' | 'register' | 'connect' } | null {
185
+ if (!state || !state.startsWith('hhweb.')) {
186
+ return null;
187
+ }
188
+
189
+ const parts = state.split('.');
190
+ if (parts.length !== 4) {
191
+ return null;
192
+ }
193
+
194
+ const [, app, flow, signature] = parts;
195
+ if (!app || !flow || !signature) {
196
+ return null;
197
+ }
198
+
199
+ if (flow !== 'login' && flow !== 'register' && flow !== 'connect') {
200
+ return null;
201
+ }
202
+
203
+ if (!this.security.verifyHmacSha256(`oauth-web-state:${app}:${flow}`, signature)) {
204
+ return null;
205
+ }
206
+
207
+ return { app, flow };
208
+ }
209
+
210
+ /** Resolves an app key to its configured base origin via the `app-urls` setting. */
211
+ private async resolveAppOrigin(app: string): Promise<string | null> {
212
+ const settings = await this.setting.getSettingValues(['app-urls']);
213
+ const appUrls: string[] = Array.isArray(settings['app-urls']) ? settings['app-urls'] : [];
214
+ const entry = appUrls.find((item) => item.split('=')[0]?.trim() === app);
215
+ if (!entry) {
216
+ return null;
217
+ }
218
+ const value = entry.slice(entry.indexOf('=') + 1).trim();
219
+ return value || null;
220
+ }
221
+
222
+ private normalizeOrigin(value?: string): string | null {
223
+ if (!value) {
224
+ return null;
225
+ }
226
+ try {
227
+ return new URL(value).origin.toLowerCase();
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
111
233
  async handleCallback({
112
234
  res,
113
235
  locale,
@@ -121,6 +243,8 @@ export class OAuthService {
121
243
  redirectUri,
122
244
  state,
123
245
  includeRefreshTokenInBody,
246
+ requestOrigin,
247
+ requestReferer,
124
248
  }: HandleCallbackProps) {
125
249
  try {
126
250
  this.logger.log(
@@ -141,6 +265,33 @@ export class OAuthService {
141
265
  );
142
266
  }
143
267
 
268
+ // Web hub flow: an app other than the OAuth hub started the flow and the hub
269
+ // forwarded the code here. Bind the code exchange to the initiating app's
270
+ // origin so a leaked/forwarded code cannot be redeemed from another origin.
271
+ const webState = this.parseWebState(state);
272
+ if (webState && (type === 'login' || type === 'register')) {
273
+ const expectedOrigin = this.normalizeOrigin(
274
+ await this.resolveAppOrigin(webState.app),
275
+ );
276
+ // Only bind cross-app forwards (the app resolves to a configured origin).
277
+ // The hub's own flow (app "self") resolves to nothing and runs locally.
278
+ if (expectedOrigin) {
279
+ const boundOrigin =
280
+ this.normalizeOrigin(requestOrigin) ??
281
+ this.normalizeOrigin(requestReferer);
282
+
283
+ if (!boundOrigin || expectedOrigin !== boundOrigin) {
284
+ throw new BadRequestException(
285
+ getLocaleText(
286
+ 'oauth.callback.invalidState',
287
+ locale,
288
+ 'OAuth callback origin is invalid. Start the sign-in flow again.',
289
+ ),
290
+ );
291
+ }
292
+ }
293
+ }
294
+
144
295
  if (
145
296
  redirectUri &&
146
297
  !(await this.consumeMobileState(state, provider, redirectUri))
@@ -238,6 +389,8 @@ export class OAuthService {
238
389
  microsoft: 'oauth-microsoft-scopes',
239
390
  'microsoft-entra-id': 'oauth-microsoft-entra-id-scopes',
240
391
  microsoft_entra_id: 'oauth-microsoft-entra-id-scopes',
392
+ apple: 'oauth-apple-scopes',
393
+ linkedin: 'oauth-linkedin-scopes',
241
394
  };
242
395
 
243
396
  private static readonly PROVIDER_ENABLED_SLUGS: Record<string, string> = {
@@ -247,6 +400,8 @@ export class OAuthService {
247
400
  microsoft: 'oauth-microsoft-enabled',
248
401
  'microsoft-entra-id': 'oauth-microsoft-entra-id-enabled',
249
402
  microsoft_entra_id: 'oauth-microsoft-entra-id-enabled',
403
+ apple: 'oauth-apple-enabled',
404
+ linkedin: 'oauth-linkedin-enabled',
250
405
  };
251
406
 
252
407
  async isProviderEnabled(provider: user_account_provider_52222e2ecb_enum): Promise<boolean> {
@@ -0,0 +1,175 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { HttpService } from '@nestjs/axios';
3
+ import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
4
+ import * as jwt from 'jsonwebtoken';
5
+ import { buildOAuthConfigFromIntegration } from '../../integration-profile/integration-profile.utils';
6
+ import { SettingService } from '../../setting/setting.service';
7
+ import { BaseOAuthProvider } from './abstract.provider';
8
+
9
+ const APPLE_TOKEN_AUDIENCE = 'https://appleid.apple.com';
10
+ // Apple accepts client_secret JWTs valid for up to 6 months; a short TTL is enough
11
+ // since a fresh one is signed on every token exchange (no caching/rotation needed).
12
+ const CLIENT_SECRET_TTL_SECONDS = 5 * 60;
13
+
14
+ @Injectable()
15
+ export class AppleProvider extends BaseOAuthProvider {
16
+
17
+ constructor(
18
+ http: HttpService,
19
+ @Inject(forwardRef(() => SettingService))
20
+ private readonly setting: SettingService,
21
+ private readonly prisma: PrismaService,
22
+ ) {
23
+ super(http);
24
+ }
25
+
26
+ getProviderType() {
27
+ return 'APPLE';
28
+ }
29
+
30
+ private async loadConfig() {
31
+ const settings = await this.setting.getSettingValues([
32
+ 'oauth-apple-profile-id',
33
+ 'api-url',
34
+ ]);
35
+ const profileSlug = String(settings['oauth-apple-profile-id'] ?? '').trim();
36
+ if (!profileSlug) throw new BadRequestException('Apple OAuth profile not configured.');
37
+
38
+ const profile = await this.prisma.integration_profile.findUnique({
39
+ where: { slug: profileSlug },
40
+ include: { integration_provider: { select: { slug: true } } },
41
+ });
42
+ if (!profile) throw new BadRequestException('Apple OAuth profile not found.');
43
+
44
+ const { clientId, teamId, keyId, privateKey } = buildOAuthConfigFromIntegration(
45
+ profile.integration_provider.slug,
46
+ profile.config,
47
+ );
48
+ if (!teamId || !keyId || !privateKey) {
49
+ throw new BadRequestException(
50
+ 'Apple OAuth profile is missing team_id, key_id or private_key.',
51
+ );
52
+ }
53
+
54
+ return {
55
+ clientId,
56
+ teamId,
57
+ keyId,
58
+ privateKey: this.normalizePrivateKey(privateKey),
59
+ apiUrl: String(settings['api-url'] ?? ''),
60
+ };
61
+ }
62
+
63
+ /** Accepts keys pasted with literal "\n" escapes, in addition to real line breaks. */
64
+ private normalizePrivateKey(raw: string): string {
65
+ const trimmed = raw.trim();
66
+ return trimmed.includes('\\n') ? trimmed.replace(/\\n/g, '\n') : trimmed;
67
+ }
68
+
69
+ /**
70
+ * Apple has no static client_secret: it must be a short-lived ES256 JWT signed
71
+ * with the private key tied to keyId, issued by teamId, for clientId (the
72
+ * Services ID). Built fresh per token exchange.
73
+ */
74
+ private buildClientSecret(
75
+ clientId: string,
76
+ teamId: string,
77
+ keyId: string,
78
+ privateKey: string,
79
+ ): string {
80
+ const now = Math.floor(Date.now() / 1000);
81
+ return jwt.sign(
82
+ {
83
+ iss: teamId,
84
+ iat: now,
85
+ exp: now + CLIENT_SECRET_TTL_SECONDS,
86
+ aud: APPLE_TOKEN_AUDIENCE,
87
+ sub: clientId,
88
+ },
89
+ privateKey,
90
+ { algorithm: 'ES256', keyid: keyId },
91
+ );
92
+ }
93
+
94
+ async getAuthUrl(_callbackPath: string) {
95
+ const { clientId, apiUrl } = await this.loadConfig();
96
+ const settings = await this.setting.getSettingValues(['oauth-apple-scopes']);
97
+ const scopes: string[] = settings['oauth-apple-scopes'] ?? ['name', 'email'];
98
+
99
+ // Apple only supports a single registered "Return URL" per app, at the backend
100
+ // (mirrors GitHub): the flow (login/register/connect) and initiating app travel
101
+ // in the signed OAuth state, never the path (see OAuthService.getWebAuthUrl).
102
+ const redirectURI = new URL('/oauth/apple/callback', apiUrl).toString();
103
+
104
+ const params = new URLSearchParams({
105
+ client_id: clientId,
106
+ redirect_uri: redirectURI,
107
+ response_type: 'code',
108
+ scope: scopes.join(' '),
109
+ });
110
+
111
+ // Apple mandates response_mode=form_post whenever `scope` is requested: the
112
+ // browser POSTs code/state to redirect_uri instead of a GET redirect. The
113
+ // dedicated POST /oauth/apple/callback route bounces it back to a GET so the
114
+ // rest of the pipeline (hub forwarding, code exchange) is identical to every
115
+ // other provider.
116
+ if (scopes.length > 0) {
117
+ params.set('response_mode', 'form_post');
118
+ }
119
+
120
+ return `https://appleid.apple.com/auth/authorize?${params}`;
121
+ }
122
+
123
+ async getProfile(code: string, _type?: string, redirectUri?: string) {
124
+ const { clientId, teamId, keyId, privateKey, apiUrl } = await this.loadConfig();
125
+
126
+ const resolvedRedirectUri =
127
+ redirectUri?.trim() || new URL('/oauth/apple/callback', apiUrl).toString();
128
+
129
+ const clientSecret = this.buildClientSecret(clientId, teamId, keyId, privateKey);
130
+
131
+ const token = await this.fetchToken({
132
+ code,
133
+ url: 'https://appleid.apple.com/auth/token',
134
+ clientId,
135
+ clientSecret,
136
+ redirectUri: resolvedRedirectUri,
137
+ });
138
+
139
+ // Apple has no profile REST endpoint: identity comes from the id_token JWT,
140
+ // delivered directly by Apple over this authenticated server-to-server call
141
+ // (never client-supplied), so decoding the payload without re-verifying the
142
+ // signature carries the same trust level as the TLS channel itself.
143
+ const claims = this.decodeIdTokenPayload(token.id_token);
144
+ const email = claims.email ? String(claims.email) : null;
145
+
146
+ return {
147
+ id: String(claims.sub ?? ''),
148
+ email,
149
+ // Apple only sends the user's name once, in the initial form_post body —
150
+ // not reachable from this GET-based hub bounce. Fall back to the email
151
+ // local-part so account creation (user.name is required) still works.
152
+ name: email ? email.split('@')[0] : 'Apple User',
153
+ picture: null,
154
+ oauth_tokens: {
155
+ access_token: token.access_token,
156
+ refresh_token: token.refresh_token,
157
+ expires_in: token.expires_in,
158
+ token_type: token.token_type,
159
+ },
160
+ };
161
+ }
162
+
163
+ private decodeIdTokenPayload(idToken: string): Record<string, unknown> {
164
+ const segments = String(idToken ?? '').split('.');
165
+ if (segments.length < 2) {
166
+ throw new BadRequestException('Apple OAuth response is missing a valid identity token.');
167
+ }
168
+ try {
169
+ const payload = Buffer.from(segments[1], 'base64url').toString('utf8');
170
+ return JSON.parse(payload);
171
+ } catch {
172
+ throw new BadRequestException('Unable to decode Apple identity token.');
173
+ }
174
+ }
175
+ }
@@ -59,11 +59,12 @@ export class FacebookProvider extends BaseOAuthProvider {
59
59
  return `https://www.facebook.com/v17.0/dialog/oauth?${params}`;
60
60
  }
61
61
 
62
- async getProfile(code: string, type: string, redirectUri?: string): Promise<any> {
62
+ async getProfile(code: string, _type?: string, redirectUri?: string): Promise<any> {
63
63
  const { clientId, clientSecret, baseUrl } = await this.loadConfig();
64
64
 
65
+ // Single, flow-less callback per provider (the flow travels in the OAuth state).
65
66
  const resolvedRedirectUri =
66
- redirectUri?.trim() || `${baseUrl}/oauth/facebook/callback/${type}`;
67
+ redirectUri?.trim() || `${baseUrl}/callback/facebook`;
67
68
 
68
69
  const token = await this.fetchToken({
69
70
  code,
@@ -60,11 +60,12 @@ export class GoogleProvider extends BaseOAuthProvider {
60
60
  return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
61
61
  }
62
62
 
63
- async getProfile(code: string, type: string, redirectUri?: string) {
63
+ async getProfile(code: string, _type?: string, redirectUri?: string) {
64
64
  const { clientId, clientSecret, baseUrl } = await this.loadConfig();
65
65
 
66
+ // Single, flow-less callback per provider (the flow travels in the OAuth state).
66
67
  const resolvedRedirectUri =
67
- redirectUri?.trim() || `${baseUrl}/callback/google/${type}`;
68
+ redirectUri?.trim() || `${baseUrl}/callback/google`;
68
69
 
69
70
  const token = await this.fetchToken({
70
71
  code,
@@ -0,0 +1,96 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { HttpService } from '@nestjs/axios';
3
+ import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
4
+ import { buildOAuthConfigFromIntegration } from '../../integration-profile/integration-profile.utils';
5
+ import { SettingService } from '../../setting/setting.service';
6
+ import { BaseOAuthProvider } from './abstract.provider';
7
+
8
+ @Injectable()
9
+ export class LinkedinProvider extends BaseOAuthProvider {
10
+
11
+ constructor(
12
+ http: HttpService,
13
+ @Inject(forwardRef(() => SettingService))
14
+ private readonly setting: SettingService,
15
+ private readonly prisma: PrismaService,
16
+ ) {
17
+ super(http);
18
+ }
19
+
20
+ getProviderType() {
21
+ return 'LINKEDIN';
22
+ }
23
+
24
+ private async loadConfig() {
25
+ const settings = await this.setting.getSettingValues([
26
+ 'oauth-linkedin-profile-id',
27
+ 'url',
28
+ ]);
29
+ const profileSlug = String(settings['oauth-linkedin-profile-id'] ?? '').trim();
30
+ if (!profileSlug) throw new BadRequestException('LinkedIn OAuth profile not configured.');
31
+
32
+ const profile = await this.prisma.integration_profile.findUnique({
33
+ where: { slug: profileSlug },
34
+ include: { integration_provider: { select: { slug: true } } },
35
+ });
36
+ if (!profile) throw new BadRequestException('LinkedIn OAuth profile not found.');
37
+
38
+ const { clientId, clientSecret } = buildOAuthConfigFromIntegration(
39
+ profile.integration_provider.slug,
40
+ profile.config,
41
+ );
42
+ return { clientId, clientSecret, baseUrl: String(settings['url'] ?? '') };
43
+ }
44
+
45
+ async getAuthUrl(callbackPath: string) {
46
+ const { clientId, baseUrl } = await this.loadConfig();
47
+ const settings = await this.setting.getSettingValues(['oauth-linkedin-scopes']);
48
+ const scopes: string[] = settings['oauth-linkedin-scopes'] ?? ['openid', 'profile', 'email'];
49
+ const redirectURI = new URL(callbackPath, baseUrl).toString();
50
+
51
+ const params = new URLSearchParams({
52
+ client_id: clientId,
53
+ redirect_uri: redirectURI,
54
+ response_type: 'code',
55
+ scope: scopes.join(' '),
56
+ });
57
+
58
+ return `https://www.linkedin.com/oauth/v2/authorization?${params}`;
59
+ }
60
+
61
+ async getProfile(code: string, _type?: string, redirectUri?: string) {
62
+ const { clientId, clientSecret, baseUrl } = await this.loadConfig();
63
+
64
+ // Single, flow-less callback per provider (the flow travels in the OAuth state).
65
+ const resolvedRedirectUri =
66
+ redirectUri?.trim() || `${baseUrl}/callback/linkedin`;
67
+
68
+ const token = await this.fetchToken({
69
+ code,
70
+ url: 'https://www.linkedin.com/oauth/v2/accessToken',
71
+ clientId,
72
+ clientSecret,
73
+ redirectUri: resolvedRedirectUri,
74
+ });
75
+
76
+ // "Sign In with LinkedIn using OpenID Connect": a single OIDC userinfo call
77
+ // replaces the legacy /v2/me + /v2/emailAddress two-request dance.
78
+ const profile = await this.fetchProfile(
79
+ token.access_token,
80
+ 'https://api.linkedin.com/v2/userinfo',
81
+ );
82
+
83
+ return {
84
+ id: profile.sub,
85
+ email: profile.email,
86
+ name: profile.name,
87
+ picture: profile.picture,
88
+ oauth_tokens: {
89
+ access_token: token.access_token,
90
+ refresh_token: token.refresh_token,
91
+ expires_in: token.expires_in,
92
+ token_type: token.token_type,
93
+ },
94
+ };
95
+ }
96
+ }
@@ -96,7 +96,7 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
96
96
  const settings = await this.setting.getSettingValues(['oauth-microsoft-entra-id-scopes']);
97
97
  const scopes: string[] = settings['oauth-microsoft-entra-id-scopes'] ?? [];
98
98
  resolvedRedirectUri =
99
- redirectUri?.trim() || this.buildRedirectUri(baseUrl, `/callback/microsoft-entra-id/${type}`);
99
+ redirectUri?.trim() || this.buildRedirectUri(baseUrl, '/callback/microsoft-entra-id');
100
100
 
101
101
  this.logger.log(
102
102
  `OAuth token exchange started: provider=MICROSOFT-ENTRA-ID type=${type} redirectUri=${resolvedRedirectUri} hasCode=${code ? 'yes' : 'no'} codeLength=${code?.length ?? 0}`,
@@ -60,11 +60,12 @@ export class MicrosoftProvider extends BaseOAuthProvider {
60
60
  return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${params}`;
61
61
  }
62
62
 
63
- async getProfile(code: string, type: string, redirectUri?: string): Promise<any> {
63
+ async getProfile(code: string, _type?: string, redirectUri?: string): Promise<any> {
64
64
  const { clientId, clientSecret, baseUrl } = await this.loadConfig();
65
65
 
66
+ // Single, flow-less callback per provider (the flow travels in the OAuth state).
66
67
  const resolvedRedirectUri =
67
- redirectUri?.trim() || `${baseUrl}/callback/microsoft/${type}`;
68
+ redirectUri?.trim() || `${baseUrl}/callback/microsoft`;
68
69
 
69
70
  const token = await this.fetchToken({
70
71
  code,
@@ -21,6 +21,17 @@ export interface PdfGenerateOptions {
21
21
  height?: string;
22
22
  }
23
23
 
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Helpers
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ /** Parses a CSS pixel size string (e.g. '1600px') into a number, falling back when absent/unparsable. */
29
+ function parsePxSize(value: string | undefined, fallback: number): number {
30
+ if (!value) return fallback;
31
+ const match = /^([\d.]+)px$/.exec(value.trim());
32
+ return match ? Math.round(Number(match[1])) : fallback;
33
+ }
34
+
24
35
  // ─────────────────────────────────────────────────────────────────────────────
25
36
  // Service
26
37
  // ─────────────────────────────────────────────────────────────────────────────
@@ -161,8 +172,15 @@ export class PdfService {
161
172
 
162
173
  const page = await browser.newPage();
163
174
 
164
- // A4 viewport for proper page layout
165
- await page.setViewportSize({ width: 794, height: 1123 });
175
+ // Viewport must match the requested PDF page size (`width`/`height`),
176
+ // otherwise CSS vw/vh units in the HTML are computed against the wrong
177
+ // box and everything laid out with them (e.g. certificate fields) ends
178
+ // up misaligned once Chromium scales the page to the paper size. Falls
179
+ // back to the A4 default (96dpi 210x297mm) when no explicit size is given.
180
+ await page.setViewportSize({
181
+ width: parsePxSize(options.width, 794),
182
+ height: parsePxSize(options.height, 1123),
183
+ });
166
184
  await page.setContent(html, { waitUntil: 'networkidle' });
167
185
 
168
186
  const pdfBuffer = await page.pdf(