| @@ -61,6 +61,10 @@ export interface MicrosoftGraphSpecBuild { · 60 unmodified lines |
| 61 | readonly authenticationTemplate: readonly Authentication[]; | 61 | readonly authenticationTemplate: readonly Authentication[]; |
| 62 | } | 62 | } |
| 63 | | 63 | |
| | 64 | export interface MicrosoftGraphUrlPolicy { |
| | 65 | readonly allowUnsafeUrlOverrides?: boolean; |
| | 66 | } |
| | 67 | |
| 64 | export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & { | 68 | export type MicrosoftGraphIntegrationConfig = OpenApiIntegrationConfig & { |
| 65 | readonly microsoftGraphPresetIds?: readonly string[]; | 69 | readonly microsoftGraphPresetIds?: readonly string[]; |
| 66 | readonly microsoftGraphCustomScopes?: readonly string[]; | 70 | readonly microsoftGraphCustomScopes?: readonly string[]; |
| @@ -180,6 +184,154 @@ const BASE_OAUTH_SCOPES = new Set(["offline_access", "openid", "profile", "email · 113 unmodified lines |
| 180 | const firstString = (values: readonly unknown[]): string | undefined => | 184 | const firstString = (values: readonly unknown[]): string | undefined => |
| 181 | values.find((value): value is string => typeof value === "string" && value.trim().length > 0); | 185 | values.find((value): value is string => typeof value === "string" && value.trim().length > 0); |
| 182 | | 186 | |
| | 187 | const parseTrustedHttpsUrl = (value: string): URL | null => { |
| | 188 | if (!URL.canParse(value)) return null; |
| | 189 | const parsed = new URL(value); |
| | 190 | if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { |
| | 191 | return null; |
| | 192 | } |
| | 193 | return parsed; |
| | 194 | }; |
| | 195 | |
| | 196 | const allowUnsafeUrl = ( |
| | 197 | value: string | undefined, |
| | 198 | policy: MicrosoftGraphUrlPolicy | undefined, |
| | 199 | ): string | undefined | null => { |
| | 200 | if (!value) return undefined; |
| | 201 | if (policy?.allowUnsafeUrlOverrides !== true) return null; |
| | 202 | return parseTrustedHttpsUrl(value) ? value : null; |
| | 203 | }; |
| | 204 | |
| | 205 | const normalizeMicrosoftGraphSpecUrl = ( |
| | 206 | value: string, |
| | 207 | policy?: MicrosoftGraphUrlPolicy, |
| | 208 | ): string | null => { |
| | 209 | if (value === MICROSOFT_GRAPH_OPENAPI_URL) return value; |
| | 210 | return allowUnsafeUrl(value, policy) ?? null; |
| | 211 | }; |
| | 212 | |
| | 213 | const MICROSOFT_GRAPH_HOSTS = new Set([ |
| | 214 | "graph.microsoft.com", |
| | 215 | "graph.microsoft.us", |
| | 216 | "dod-graph.microsoft.us", |
| | 217 | "microsoftgraph.chinacloudapi.cn", |
| | 218 | ]); |
| | 219 | |
| | 220 | const normalizeMicrosoftGraphBaseUrl = ( |
| | 221 | value: string | undefined, |
| | 222 | policy?: MicrosoftGraphUrlPolicy, |
| | 223 | ): string | undefined | null => { |
| | 224 | const unsafe = allowUnsafeUrl(value, policy); |
| | 225 | if (unsafe !== null) return unsafe; |
| | 226 | if (!value) return undefined; |
| | 227 | const parsed = parseTrustedHttpsUrl(value); |
| | 228 | if (!parsed || !MICROSOFT_GRAPH_HOSTS.has(parsed.hostname.toLowerCase())) return null; |
| | 229 | if (!/^\/(?:v1\.0|beta)(?:\/)?$/.test(parsed.pathname)) return null; |
| | 230 | if (parsed.search) return null; |
| | 231 | return parsed.toString().replace(/\/$/, ""); |
| | 232 | }; |
| | 233 | |
| | 234 | const MICROSOFT_IDENTITY_HOSTS = new Set([ |
| | 235 | "login.microsoftonline.com", |
| | 236 | "login.microsoftonline.us", |
| | 237 | "login.partner.microsoftonline.cn", |
| | 238 | ]); |
| | 239 | |
| | 240 | const normalizeMicrosoftOAuthEndpointUrl = ( |
| | 241 | value: string, |
| | 242 | endpoint: "authorize" | "token", |
| | 243 | policy?: MicrosoftGraphUrlPolicy, |
| | 244 | ): string | null => { |
| | 245 | const unsafe = allowUnsafeUrl(value, policy); |
| | 246 | if (unsafe !== null) return unsafe ?? null; |
| | 247 | const parsed = parseTrustedHttpsUrl(value); |
| | 248 | if (!parsed || !MICROSOFT_IDENTITY_HOSTS.has(parsed.hostname.toLowerCase())) return null; |
| | 249 | if (parsed.search) return null; |
| | 250 | const suffix = endpoint === "authorize" ? "authorize" : "token"; |
| | 251 | return /^\/[^/]+\/oauth2\/v2\.0\/(?:authorize|token)$/.test(parsed.pathname) && |
| | 252 | parsed.pathname.endsWith(`/${suffix}`) |
| | 253 | ? parsed.toString() |
| | 254 | : null; |
| | 255 | }; |
| | 256 | |
| | 257 | const validateSelectionUrls = ( |
| | 258 | selection: ReturnType<typeof normalizeSelection>, |
| | 259 | policy?: MicrosoftGraphUrlPolicy, |
| | 260 | ): Effect.Effect<ReturnType<typeof normalizeSelection>, OpenApiParseError> => |
| | 261 | Effect.gen(function* () { |
| | 262 | const specUrl = normalizeMicrosoftGraphSpecUrl(selection.specUrl, policy); |
| | 263 | if (!specUrl) { |
| | 264 | return yield* new OpenApiParseError({ |
| | 265 | message: "Microsoft Graph specUrl must point to the trusted Microsoft Graph OpenAPI source", |
| | 266 | }); |
| | 267 | } |
| | 268 | const baseUrl = normalizeMicrosoftGraphBaseUrl(selection.baseUrl, policy); |
| | 269 | if (baseUrl === null) { |
| | 270 | return yield* new OpenApiParseError({ |
| | 271 | message: "Microsoft Graph baseUrl must point to a supported Microsoft Graph endpoint", |
| | 272 | }); |
| | 273 | } |
| | 274 | const authorizationUrl = selection.authorizationUrl |
| | 275 | ? normalizeMicrosoftOAuthEndpointUrl(selection.authorizationUrl, "authorize", policy) |
| | 276 | : undefined; |
| | 277 | if (selection.authorizationUrl && !authorizationUrl) { |
| | 278 | return yield* new OpenApiParseError({ |
| | 279 | message: "Microsoft authorizationUrl must point to a supported Microsoft identity endpoint", |
| | 280 | }); |
| | 281 | } |
| | 282 | const tokenUrl = selection.tokenUrl |
| | 283 | ? normalizeMicrosoftOAuthEndpointUrl(selection.tokenUrl, "token", policy) |
| | 284 | : undefined; |
| | 285 | if (selection.tokenUrl && !tokenUrl) { |
| | 286 | return yield* new OpenApiParseError({ |
| | 287 | message: "Microsoft tokenUrl must point to a supported Microsoft identity endpoint", |
| | 288 | }); |
| | 289 | } |
| | 290 | const clientCredentialsTokenUrl = selection.clientCredentialsTokenUrl |
| | 291 | ? normalizeMicrosoftOAuthEndpointUrl(selection.clientCredentialsTokenUrl, "token", policy) |
| | 292 | : undefined; |
| | 293 | if (selection.clientCredentialsTokenUrl && !clientCredentialsTokenUrl) { |
| | 294 | return yield* new OpenApiParseError({ |
| | 295 | message: |
| | 296 | "Microsoft clientCredentialsTokenUrl must point to a supported Microsoft identity endpoint", |
| | 297 | }); |
| | 298 | } |
| | 299 | return { |
| | 300 | ...selection, |
| | 301 | specUrl, |
| | 302 | ...(baseUrl ? { baseUrl } : { baseUrl: undefined }), |
| | 303 | ...(authorizationUrl ? { authorizationUrl } : { authorizationUrl: undefined }), |
| | 304 | ...(tokenUrl ? { tokenUrl } : { tokenUrl: undefined }), |
| | 305 | ...(clientCredentialsTokenUrl |
| | 306 | ? { clientCredentialsTokenUrl } |
| | 307 | : { clientCredentialsTokenUrl: undefined }), |
| | 308 | }; |
| | 309 | }); |
| | 310 | |
| | 311 | const validateResolvedOAuthEndpoints = ( |
| | 312 | endpoints: MicrosoftOAuthEndpoints, |
| | 313 | policy?: MicrosoftGraphUrlPolicy, |
| | 314 | ): Effect.Effect<MicrosoftOAuthEndpoints, OpenApiParseError> => |
| | 315 | Effect.gen(function* () { |
| | 316 | const authorizationUrl = normalizeMicrosoftOAuthEndpointUrl( |
| | 317 | endpoints.authorizationUrl, |
| | 318 | "authorize", |
| | 319 | policy, |
| | 320 | ); |
| | 321 | const tokenUrl = normalizeMicrosoftOAuthEndpointUrl(endpoints.tokenUrl, "token", policy); |
| | 322 | const clientCredentialsTokenUrl = normalizeMicrosoftOAuthEndpointUrl( |
| | 323 | endpoints.clientCredentialsTokenUrl, |
| | 324 | "token", |
| | 325 | policy, |
| | 326 | ); |
| | 327 | if (!authorizationUrl || !tokenUrl || !clientCredentialsTokenUrl) { |
| | 328 | return yield* new OpenApiParseError({ |
| | 329 | message: "Microsoft OAuth endpoints must point to supported Microsoft identity endpoints", |
| | 330 | }); |
| | 331 | } |
| | 332 | return { authorizationUrl, tokenUrl, clientCredentialsTokenUrl }; |
| | 333 | }); |
| | 334 | |
| 183 | const recordValues = (value: unknown): readonly unknown[] => | 335 | const recordValues = (value: unknown): readonly unknown[] => |
| 184 | isRecord(value) ? Object.values(value) : []; | 336 | isRecord(value) ? Object.values(value) : []; |
| 185 | | 337 | |
| @@ -490,9 +642,10 @@ const streamSelectedScopes = ( · 304 unmodified lines |
| 490 | export const buildMicrosoftGraphOpenApiSpec = ( | 642 | export const buildMicrosoftGraphOpenApiSpec = ( |
| 491 | input: MicrosoftGraphSelectionInput, | 643 | input: MicrosoftGraphSelectionInput, |
| 492 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | 644 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| | 645 | urlPolicy?: MicrosoftGraphUrlPolicy, |
| 493 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => | 646 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => |
| 494 | Effect.gen(function* () { | 647 | Effect.gen(function* () { |
| 495 | const selection = normalizeSelection(input); | 648 | const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); |
| 496 | const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( | 649 | const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( |
| 497 | Effect.provide(httpClientLayer), | 650 | Effect.provide(httpClientLayer), |
| 498 | ); | 651 | ); |
| @@ -512,7 +665,10 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 13 unmodified lines |
| 512 | // Head + small components (servers + securitySchemes) parse cheaply and | 665 | // Head + small components (servers + securitySchemes) parse cheaply and |
| 513 | // carry everything `resolveOAuthEndpoints` needs. | 666 | // carry everything `resolveOAuthEndpoints` needs. |
| 514 | const headDoc = { ...parseHead(structure), components: parseSmallComponents(structure) }; | 667 | const headDoc = { ...parseHead(structure), components: parseSmallComponents(structure) }; |
| 515 | const endpoints = resolveOAuthEndpoints(headDoc, selection); | 668 | const endpoints = yield* validateResolvedOAuthEndpoints( |
| | 669 | resolveOAuthEndpoints(headDoc, selection), |
| | 670 | urlPolicy, |
| | 671 | ); |
| 516 | | 672 | |
| 517 | const permissionsReference = | 673 | const permissionsReference = |
| 518 | selection.coversFullGraph === true | 674 | selection.coversFullGraph === true |