@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.
- package/README.md +83 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +4 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.utils.d.ts +6 -0
- package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.utils.js +1 -1
- package/dist/integration-profile/integration-profile.utils.js.map +1 -1
- package/dist/oauth/oauth.controller.d.ts +11 -3
- package/dist/oauth/oauth.controller.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.js +52 -14
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.module.d.ts.map +1 -1
- package/dist/oauth/oauth.module.js +4 -0
- package/dist/oauth/oauth.module.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +38 -2
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js +122 -5
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/oauth/providers/apple.provider.d.ts +34 -0
- package/dist/oauth/providers/apple.provider.d.ts.map +1 -0
- package/dist/oauth/providers/apple.provider.js +197 -0
- package/dist/oauth/providers/apple.provider.js.map +1 -0
- package/dist/oauth/providers/facebook.provider.d.ts +1 -1
- package/dist/oauth/providers/facebook.provider.d.ts.map +1 -1
- package/dist/oauth/providers/facebook.provider.js +3 -2
- package/dist/oauth/providers/facebook.provider.js.map +1 -1
- package/dist/oauth/providers/google.provider.d.ts +1 -1
- package/dist/oauth/providers/google.provider.d.ts.map +1 -1
- package/dist/oauth/providers/google.provider.js +3 -2
- package/dist/oauth/providers/google.provider.js.map +1 -1
- package/dist/oauth/providers/linkedin.provider.d.ts +25 -0
- package/dist/oauth/providers/linkedin.provider.d.ts.map +1 -0
- package/dist/oauth/providers/linkedin.provider.js +99 -0
- package/dist/oauth/providers/linkedin.provider.js.map +1 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.js +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/oauth/providers/microsoft.provider.d.ts +1 -1
- package/dist/oauth/providers/microsoft.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft.provider.js +3 -2
- package/dist/oauth/providers/microsoft.provider.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +19 -2
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +4 -1
- package/dist/setting/setting.service.js.map +1 -1
- package/hedhog/data/integration_provider.yaml +16 -0
- package/hedhog/data/setting_group.yaml +98 -0
- package/hedhog/data/setting_subgroup.yaml +16 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +2 -0
- package/hedhog/frontend/app/dashboard/dashboard.css.ejs +11 -11
- package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +42 -0
- package/hedhog/frontend/app/menu/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/users/user-sheet.tsx.ejs +8 -0
- package/hedhog/frontend/messages/en.json +35 -0
- package/hedhog/frontend/messages/pt.json +35 -0
- package/hedhog/table/user_account.yaml +1 -0
- package/package.json +3 -3
- package/src/access-log/access-log.service.spec.ts +235 -0
- package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
- package/src/auth/auth.service.spec.ts +891 -0
- package/src/auth/guards/auth.guard.spec.ts +177 -0
- package/src/challenge/challenge.service.spec.ts +334 -0
- package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
- package/src/file/image-optimization.service.spec.ts +283 -0
- package/src/file/provider/provider.factory.spec.ts +111 -0
- package/src/install/install.service.spec.ts +139 -0
- package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
- package/src/integration/services/inbox.service.spec.ts +183 -0
- package/src/integration/services/integration-link.service.spec.ts +217 -0
- package/src/integration/services/outbox.service.spec.ts +290 -0
- package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
- package/src/integration-profile/integration-profile.secrets.ts +4 -0
- package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
- package/src/integration-profile/integration-profile.utils.ts +14 -1
- package/src/locale/locale.service.spec.ts +78 -0
- package/src/mail/mail.service.spec.ts +257 -0
- package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
- package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
- package/src/mcp/mcp-api-key.service.spec.ts +125 -0
- package/src/mcp/mcp-registry.service.spec.ts +96 -0
- package/src/mcp/mcp-server.service.spec.ts +187 -0
- package/src/menu/menu.service.spec.ts +76 -0
- package/src/notification/notification.service.spec.ts +297 -0
- package/src/oauth/oauth.controller.ts +53 -10
- package/src/oauth/oauth.module.ts +4 -0
- package/src/oauth/oauth.service.spec.ts +332 -0
- package/src/oauth/oauth.service.ts +156 -1
- package/src/oauth/providers/apple.provider.ts +175 -0
- package/src/oauth/providers/facebook.provider.ts +3 -2
- package/src/oauth/providers/google.provider.ts +3 -2
- package/src/oauth/providers/linkedin.provider.ts +96 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
- package/src/oauth/providers/microsoft.provider.ts +3 -2
- package/src/pdf/pdf.service.ts +20 -2
- package/src/role/guards/role.guard.spec.ts +157 -0
- package/src/role/role.service.spec.ts +179 -0
- package/src/session/session.service.spec.ts +376 -0
- package/src/setting/setting.service.spec.ts +316 -0
- package/src/setting/setting.service.ts +4 -1
- package/src/task/task.service.spec.ts +47 -0
- package/src/token/token.service.spec.ts +351 -0
- package/src/user/user.service.spec.ts +256 -0
- package/src/utils/locale-context.spec.ts +22 -0
- package/src/utils/webauthn-origin.spec.ts +73 -0
- 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
|
-
|
|
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,
|
|
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}/
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
68
|
+
redirectUri?.trim() || `${baseUrl}/callback/microsoft`;
|
|
68
69
|
|
|
69
70
|
const token = await this.fetchToken({
|
|
70
71
|
code,
|
package/src/pdf/pdf.service.ts
CHANGED
|
@@ -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
|
-
//
|
|
165
|
-
|
|
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(
|