diff --git a/apps/server/src/microsoftGraph/MicrosoftGraphConnection.test.ts b/apps/server/src/microsoftGraph/MicrosoftGraphConnection.test.ts new file mode 100644 index 00000000000..551b7b8ea55 --- /dev/null +++ b/apps/server/src/microsoftGraph/MicrosoftGraphConnection.test.ts @@ -0,0 +1,379 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { MicrosoftGraphConnectionError } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as MicrosoftGraphConnection from "./MicrosoftGraphConnection.ts"; + +type GraphHttpRequest = Parameters< + MicrosoftGraphConnection.MicrosoftGraphHttpClientShape["requestJson"] +>[0]; + +interface FakeGraphResponse { + readonly status: number; + readonly ok: boolean; + readonly json: unknown; +} + +const graphTokenUrl = `https://login.microsoftonline.com/${MicrosoftGraphConnection.MicrosoftGraphTenantId}/oauth2/v2.0/token`; +const graphDeviceCodeUrl = `https://login.microsoftonline.com/${MicrosoftGraphConnection.MicrosoftGraphTenantId}/oauth2/v2.0/devicecode`; + +function makeGraphEffectLayer( + handler: ( + request: GraphHttpRequest, + ) => Effect.Effect, +) { + const configLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-msgraph-test-" }); + const secretLayer = ServerSecretStore.layer.pipe(Layer.provide(configLayer)); + const httpLayer = Layer.succeed(MicrosoftGraphConnection.MicrosoftGraphHttpClient, { + requestJson: handler, + }); + const graphLayer = MicrosoftGraphConnection.layer.pipe( + Layer.provide(httpLayer), + Layer.provide(secretLayer), + ); + return Layer.merge(graphLayer, secretLayer); +} + +function makeGraphLayer(handler: (request: GraphHttpRequest) => FakeGraphResponse) { + return makeGraphEffectLayer((request) => Effect.sync(() => handler(request))); +} + +function deviceCodeResponse() { + return { + status: 200, + ok: true, + json: { + device_code: "device-code-secret", + user_code: "ABCD-EFGH", + verification_uri: "https://microsoft.com/devicelogin", + expires_in: 900, + interval: 1, + message: "Use this code.", + }, + } satisfies FakeGraphResponse; +} + +function tokenResponse(input?: { + readonly accessToken?: string; + readonly refreshToken?: string; + readonly expiresIn?: number; + readonly scopes?: string; +}) { + return { + status: 200, + ok: true, + json: { + token_type: "Bearer", + access_token: input?.accessToken ?? "access-token", + refresh_token: input?.refreshToken ?? "refresh-token", + expires_in: input?.expiresIn ?? 3600, + scope: input?.scopes ?? "User.Read Mail.Read Calendars.Read offline_access", + }, + } satisfies FakeGraphResponse; +} + +function accountResponse() { + return { + status: 200, + ok: true, + json: { + id: "user-id", + displayName: "David Balderston", + mail: "david@example.com", + userPrincipalName: "david@example.com", + }, + } satisfies FakeGraphResponse; +} + +it.layer(NodeServices.layer)("MicrosoftGraphConnection", (it) => { + it.effect("completes device-code sign-in and exposes only redacted status", () => { + let tokenPollCount = 0; + return Effect.gen(function* () { + const service = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; + const secrets = yield* ServerSecretStore.ServerSecretStore; + + const start = yield* service.startSignIn({}); + assert.equal(start.accessLevel, "read_only"); + assert.equal(start.clientId, MicrosoftGraphConnection.MicrosoftGraphClientId); + assert.equal(start.tenantId, MicrosoftGraphConnection.MicrosoftGraphTenantId); + assert.equal(start.userCode, "ABCD-EFGH"); + assert.equal("deviceCode" in start, false); + + const pending = yield* service.pollSignIn({ flowId: start.flowId }); + assert.equal(pending.state, "pending"); + assert.equal(pending.status.state, "not_connected"); + + const connected = yield* service.pollSignIn({ flowId: start.flowId }); + assert.equal(connected.state, "connected"); + assert.equal(connected.status.state, "connected"); + assert.equal(connected.status.accessLevel, "read_only"); + assert.equal(connected.status.account?.userPrincipalName, "david@example.com"); + assert.deepStrictEqual(connected.status.grantedScopes, [ + "User.Read", + "Mail.Read", + "Calendars.Read", + "offline_access", + ]); + assert.equal("accessToken" in connected.status, false); + assert.equal("refreshToken" in connected.status, false); + + const encoded = yield* secrets.get( + MicrosoftGraphConnection.MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME, + ); + assert.isTrue(Option.isSome(encoded)); + if (Option.isSome(encoded)) { + const persisted = new TextDecoder().decode(encoded.value); + assert.include(persisted, "refresh-token"); + assert.include(persisted, '"accessLevel":"read_only"'); + assert.notInclude(persisted, "access-token"); + assert.notInclude(persisted, "device-code-secret"); + } + }).pipe( + Effect.provide( + makeGraphLayer((request) => { + if (request.url === graphDeviceCodeUrl) { + assert.equal(request.form?.client_id, MicrosoftGraphConnection.MicrosoftGraphClientId); + assert.equal(request.form?.scope, "User.Read Mail.Read Calendars.Read offline_access"); + return deviceCodeResponse(); + } + + if (request.url === graphTokenUrl && request.form?.grant_type?.includes("device_code")) { + const currentTokenPoll = tokenPollCount; + tokenPollCount += 1; + if (currentTokenPoll === 0) { + return { + status: 400, + ok: false, + json: { + error: "authorization_pending", + error_description: "Authorization pending.", + }, + }; + } + assert.equal(request.form.device_code, "device-code-secret"); + return tokenResponse(); + } + + if (request.url.startsWith("https://graph.microsoft.com/v1.0/me?")) { + assert.equal(request.headers?.authorization, "Bearer access-token"); + return accountResponse(); + } + + throw new Error(`Unexpected request: ${request.url}`); + }), + ), + ); + }); + + it.effect("requests and persists the full-access scope profile", () => + Effect.gen(function* () { + const service = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; + const secrets = yield* ServerSecretStore.ServerSecretStore; + + const start = yield* service.startSignIn({ accessLevel: "full" }); + assert.equal(start.accessLevel, "full"); + assert.deepStrictEqual(start.requiredScopes, [ + "User.Read", + "Mail.ReadWrite", + "Mail.Send", + "Calendars.ReadWrite", + "offline_access", + ]); + + const connected = yield* service.pollSignIn({ flowId: start.flowId }); + assert.equal(connected.state, "connected"); + assert.equal(connected.status.accessLevel, "full"); + assert.deepStrictEqual(connected.status.requiredScopes, start.requiredScopes); + assert.deepStrictEqual(connected.status.grantedScopes, start.requiredScopes); + + const encoded = yield* secrets.get( + MicrosoftGraphConnection.MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME, + ); + assert.isTrue(Option.isSome(encoded)); + if (Option.isSome(encoded)) { + const persisted = new TextDecoder().decode(encoded.value); + assert.include(persisted, '"accessLevel":"full"'); + assert.include(persisted, "refresh-token"); + assert.notInclude(persisted, "access-token"); + } + }).pipe( + Effect.provide( + makeGraphLayer((request) => { + if (request.url === graphDeviceCodeUrl) { + assert.equal( + request.form?.scope, + "User.Read Mail.ReadWrite Mail.Send Calendars.ReadWrite offline_access", + ); + return deviceCodeResponse(); + } + if (request.url === graphTokenUrl && request.form?.grant_type?.includes("device_code")) { + return tokenResponse({ + scopes: "User.Read Mail.ReadWrite Mail.Send Calendars.ReadWrite offline_access", + }); + } + if (request.url.startsWith("https://graph.microsoft.com/v1.0/me?")) { + return accountResponse(); + } + throw new Error(`Unexpected request: ${request.url}`); + }), + ), + ), + ); + + it.effect("refreshes before server-side Graph requests using the stored refresh token", () => { + const requests: GraphHttpRequest[] = []; + return Effect.gen(function* () { + const service = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; + + const start = yield* service.startSignIn({ accessLevel: "read_only" }); + const connected = yield* service.pollSignIn({ flowId: start.flowId }); + assert.equal(connected.state, "connected"); + + const messages = yield* service.requestGraphJson({ + path: "/v1.0/me/messages?$top=1", + }); + + assert.deepStrictEqual(messages, { value: [{ id: "message-1" }] }); + const refreshRequest = requests.find( + (request) => request.url === graphTokenUrl && request.form?.grant_type === "refresh_token", + ); + assert.equal( + refreshRequest?.form?.scope, + "User.Read Mail.Read Calendars.Read offline_access", + ); + assert.deepStrictEqual( + requests.map((request) => + request.url === graphTokenUrl && request.form?.grant_type === "refresh_token" + ? "refresh" + : request.url, + ), + [ + graphDeviceCodeUrl, + graphTokenUrl, + "https://graph.microsoft.com/v1.0/me?$select=id,displayName,mail,userPrincipalName", + "refresh", + "https://graph.microsoft.com/v1.0/me/messages?$top=1", + ], + ); + }).pipe( + Effect.provide( + makeGraphLayer((request) => { + requests.push(request); + if (request.url === graphDeviceCodeUrl) { + return deviceCodeResponse(); + } + if (request.url === graphTokenUrl && request.form?.grant_type?.includes("device_code")) { + return tokenResponse({ + accessToken: "soon-expiring-access-token", + refreshToken: "refresh-token", + expiresIn: 60, + }); + } + if (request.url === graphTokenUrl && request.form?.grant_type === "refresh_token") { + assert.equal(request.form.refresh_token, "refresh-token"); + return tokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "new-refresh-token", + }); + } + if (request.url.startsWith("https://graph.microsoft.com/v1.0/me?")) { + assert.equal(request.headers?.authorization, "Bearer soon-expiring-access-token"); + return accountResponse(); + } + if (request.url === "https://graph.microsoft.com/v1.0/me/messages?$top=1") { + assert.equal(request.headers?.authorization, "Bearer refreshed-access-token"); + return { status: 200, ok: true, json: { value: [{ id: "message-1" }] } }; + } + throw new Error(`Unexpected request: ${request.url}`); + }), + ), + ); + }); + + it.effect("disconnects by removing the saved server-side credential", () => + Effect.gen(function* () { + const service = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; + const secrets = yield* ServerSecretStore.ServerSecretStore; + + const start = yield* service.startSignIn({ accessLevel: "read_only" }); + const connected = yield* service.pollSignIn({ flowId: start.flowId }); + assert.equal(connected.status.state, "connected"); + + const disconnected = yield* service.disconnect(); + assert.equal(disconnected.status.state, "not_connected"); + const status = yield* service.getStatus(); + assert.equal(status.state, "not_connected"); + assert.isTrue( + Option.isNone( + yield* secrets.get(MicrosoftGraphConnection.MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME), + ), + ); + }).pipe( + Effect.provide( + makeGraphLayer((request) => { + if (request.url === graphDeviceCodeUrl) return deviceCodeResponse(); + if (request.url === graphTokenUrl) return tokenResponse(); + if (request.url.startsWith("https://graph.microsoft.com/v1.0/me?")) { + return accountResponse(); + } + throw new Error(`Unexpected request: ${request.url}`); + }), + ), + ), + ); + + it.effect("keeps token-bearing HTTP causes out of public errors", () => + Effect.gen(function* () { + const service = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; + + const error = yield* Effect.flip(service.startSignIn({ accessLevel: "read_only" })); + assert.equal(error._tag, "MicrosoftGraphConnectionError"); + assert.equal(error.code, "invalid_response"); + assert.equal(error.cause, undefined); + }).pipe( + Effect.provide( + makeGraphEffectLayer(() => + Effect.fail( + new MicrosoftGraphConnectionError({ + code: "invalid_response", + message: "Microsoft Graph HTTP request failed.", + cause: { + form: { + refresh_token: "refresh-token-secret", + device_code: "device-code-secret", + }, + headers: { + authorization: "Bearer access-token-secret", + }, + }, + }), + ), + ), + ), + ), + ); + + it.effect("rejects non-root-relative internal Graph paths", () => + Effect.gen(function* () { + const service = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; + const rejected = yield* service + .requestGraphJson({ path: "//example.test/v1.0/me" as `/${string}` }) + .pipe( + Effect.match({ + onFailure: (error) => { + assert.equal(error.code, "graph_error"); + assert.match(error.message, /root-relative/); + return true; + }, + onSuccess: () => false, + }), + ); + assert.equal(rejected, true); + }).pipe(Effect.provide(makeGraphLayer(() => deviceCodeResponse()))), + ); +}); diff --git a/apps/server/src/microsoftGraph/MicrosoftGraphConnection.ts b/apps/server/src/microsoftGraph/MicrosoftGraphConnection.ts new file mode 100644 index 00000000000..354070c2e0f --- /dev/null +++ b/apps/server/src/microsoftGraph/MicrosoftGraphConnection.ts @@ -0,0 +1,710 @@ +import { + MicrosoftGraphAccount as MicrosoftGraphAccountSchema, + MicrosoftGraphAccessLevel, + MicrosoftGraphClientId, + MicrosoftGraphConnectionError, + MicrosoftGraphFullAccessScopes, + MicrosoftGraphReadOnlyScopes, + MicrosoftGraphTenantId, + type MicrosoftGraphAccount, + type MicrosoftGraphConnectionStatus, + type MicrosoftGraphDisconnectResult, + type MicrosoftGraphPollSignInInput, + type MicrosoftGraphPollSignInResult, + type MicrosoftGraphStartSignInInput, + type MicrosoftGraphStartSignInResult, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +export { + MicrosoftGraphClientId, + MicrosoftGraphFullAccessScopes, + MicrosoftGraphReadOnlyScopes, + MicrosoftGraphTenantId, +}; + +export const MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME = "microsoft-graph-refresh-token-cache"; +const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const GRAPH_BASE_URL = "https://graph.microsoft.com"; +const ACCESS_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000; + +type HttpMethod = "GET" | "POST"; + +interface JsonHttpRequest { + readonly method: HttpMethod; + readonly url: string; + readonly headers?: Readonly>; + readonly form?: Readonly>; + readonly json?: unknown; +} + +interface JsonHttpResponse { + readonly status: number; + readonly ok: boolean; + readonly json: unknown; +} + +export interface MicrosoftGraphHttpClientShape { + readonly requestJson: ( + request: JsonHttpRequest, + ) => Effect.Effect; +} + +export class MicrosoftGraphHttpClient extends Context.Service< + MicrosoftGraphHttpClient, + MicrosoftGraphHttpClientShape +>()("t3/microsoftGraph/MicrosoftGraphConnection/MicrosoftGraphHttpClient") {} + +export interface MicrosoftGraphRequestJsonInput { + readonly path: `/${string}`; + readonly method?: "GET"; +} + +export interface MicrosoftGraphConnectionShape { + readonly getStatus: () => Effect.Effect< + MicrosoftGraphConnectionStatus, + MicrosoftGraphConnectionError + >; + readonly startSignIn: ( + input: MicrosoftGraphStartSignInInput, + ) => Effect.Effect; + readonly pollSignIn: ( + input: MicrosoftGraphPollSignInInput, + ) => Effect.Effect; + readonly disconnect: () => Effect.Effect< + MicrosoftGraphDisconnectResult, + MicrosoftGraphConnectionError + >; + readonly requestGraphJson: ( + input: MicrosoftGraphRequestJsonInput, + ) => Effect.Effect; +} + +export class MicrosoftGraphConnection extends Context.Service< + MicrosoftGraphConnection, + MicrosoftGraphConnectionShape +>()("t3/microsoftGraph/MicrosoftGraphConnection") {} + +const PersistedCredential = Schema.Struct({ + version: Schema.Literal(1), + clientId: Schema.Literal(MicrosoftGraphClientId), + tenantId: Schema.Literal(MicrosoftGraphTenantId), + refreshToken: Schema.String, + accessLevel: Schema.optional(MicrosoftGraphAccessLevel), + grantedScopes: Schema.Array(Schema.String), + account: Schema.NullOr(MicrosoftGraphAccountSchema), + updatedAt: Schema.String, +}); +type PersistedCredential = typeof PersistedCredential.Type; + +const PersistedCredentialJson = Schema.fromJsonString(PersistedCredential); +const decodePersistedCredentialJson = Schema.decodeUnknownEffect(PersistedCredentialJson); +const encodePersistedCredentialJson = Schema.encodeEffect(PersistedCredentialJson); + +interface ActiveAccessToken { + readonly accessToken: string; + readonly expiresAtEpochMs: number; + readonly grantedScopes: ReadonlyArray; +} + +interface ParsedTokenResponse extends ActiveAccessToken { + readonly refreshToken: string; +} + +interface PendingDeviceFlow { + readonly flowId: string; + readonly deviceCode: string; + readonly accessLevel: MicrosoftGraphAccessLevel; + readonly expiresAtEpochMs: number; + readonly intervalSeconds: number; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function connectionError( + code: + | "not_connected" + | "flow_not_found" + | "flow_expired" + | "oauth_error" + | "graph_error" + | "storage_error" + | "invalid_response", + message: string, + cause?: unknown, +) { + return new MicrosoftGraphConnectionError({ + code, + message, + ...(cause === undefined ? {} : { cause }), + }); +} + +function redactConnectionError( + error: MicrosoftGraphConnectionError, +): MicrosoftGraphConnectionError { + return new MicrosoftGraphConnectionError({ + code: error.code, + message: error.message, + }); +} + +function exposeConnectionEffect( + effect: Effect.Effect, +): Effect.Effect { + return effect.pipe(Effect.mapError(redactConnectionError)); +} + +function isoFromEpochMs(epochMs: number): string { + return DateTime.formatIso(DateTime.makeUnsafe(epochMs)); +} + +function scopesForAccessLevel(accessLevel: MicrosoftGraphAccessLevel): ReadonlyArray { + return accessLevel === "full" ? MicrosoftGraphFullAccessScopes : MicrosoftGraphReadOnlyScopes; +} + +function scopeStringForAccessLevel(accessLevel: MicrosoftGraphAccessLevel): string { + return scopesForAccessLevel(accessLevel).join(" "); +} + +function splitScopes( + scope: string | undefined, + fallbackScopes: ReadonlyArray, +): ReadonlyArray { + const scopes = scope?.trim().split(/\s+/u).filter(Boolean) ?? []; + return scopes.length > 0 ? scopes : [...fallbackScopes]; +} + +function credentialAccessLevel(credential: PersistedCredential): MicrosoftGraphAccessLevel { + // Credentials created by the original connector only had the read-only scope set. + return credential.accessLevel ?? "read_only"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringField(value: unknown, key: string): string | undefined { + if (!isRecord(value)) return undefined; + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field : undefined; +} + +function numberField(value: unknown, key: string): number | undefined { + if (!isRecord(value)) return undefined; + const field = value[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +function nullableStringField(value: unknown, key: string): string | null { + if (!isRecord(value)) return null; + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field : null; +} + +function oauthError(response: JsonHttpResponse): string | undefined { + return stringField(response.json, "error"); +} + +function oauthErrorDescription(response: JsonHttpResponse): string { + return ( + stringField(response.json, "error_description") ?? + stringField(response.json, "error") ?? + `Microsoft identity platform returned HTTP ${response.status}.` + ); +} + +function tokenEndpoint(): string { + return `https://login.microsoftonline.com/${MicrosoftGraphTenantId}/oauth2/v2.0/token`; +} + +function deviceCodeEndpoint(): string { + return `https://login.microsoftonline.com/${MicrosoftGraphTenantId}/oauth2/v2.0/devicecode`; +} + +function toStatus( + credential: PersistedCredential | null, + activeAccessToken: ActiveAccessToken | null, +): MicrosoftGraphConnectionStatus { + const accessLevel = credential ? credentialAccessLevel(credential) : null; + return { + state: credential ? "connected" : "not_connected", + accessLevel, + account: credential?.account ?? null, + clientId: MicrosoftGraphClientId, + tenantId: MicrosoftGraphTenantId, + requiredScopes: [...scopesForAccessLevel(accessLevel ?? "read_only")], + grantedScopes: credential?.grantedScopes ?? [], + accessTokenExpiresAt: activeAccessToken + ? isoFromEpochMs(activeAccessToken.expiresAtEpochMs) + : null, + updatedAt: credential?.updatedAt ?? null, + }; +} + +function parseDeviceCodeResponse( + response: JsonHttpResponse, + flowId: string, + accessLevel: MicrosoftGraphAccessLevel, + nowEpochMs: number, +): Effect.Effect< + { + readonly pending: PendingDeviceFlow; + readonly result: MicrosoftGraphStartSignInResult; + }, + MicrosoftGraphConnectionError +> { + const deviceCode = stringField(response.json, "device_code"); + const userCode = stringField(response.json, "user_code"); + const verificationUri = + stringField(response.json, "verification_uri") ?? + stringField(response.json, "verification_url"); + const expiresIn = numberField(response.json, "expires_in"); + const interval = numberField(response.json, "interval") ?? 5; + const message = stringField(response.json, "message"); + + if (!deviceCode || !userCode || !verificationUri || !expiresIn) { + return Effect.fail( + connectionError( + "invalid_response", + "Microsoft identity platform returned an incomplete device-code response.", + ), + ); + } + + const expiresAtEpochMs = nowEpochMs + expiresIn * 1000; + const intervalSeconds = Math.max(1, Math.floor(interval)); + return Effect.succeed({ + pending: { + flowId, + deviceCode, + accessLevel, + expiresAtEpochMs, + intervalSeconds, + }, + result: { + accessLevel, + flowId, + verificationUri, + verificationUriComplete: stringField(response.json, "verification_uri_complete") ?? null, + userCode, + message: message ?? `Open ${verificationUri} and enter code ${userCode}.`, + expiresAt: isoFromEpochMs(expiresAtEpochMs), + intervalSeconds, + clientId: MicrosoftGraphClientId, + tenantId: MicrosoftGraphTenantId, + requiredScopes: [...scopesForAccessLevel(accessLevel)], + }, + }); +} + +function parseTokenResponse( + response: JsonHttpResponse, + nowEpochMs: number, + accessLevel: MicrosoftGraphAccessLevel, + existing?: PersistedCredential, +): Effect.Effect { + const accessToken = stringField(response.json, "access_token"); + const refreshToken = stringField(response.json, "refresh_token") ?? existing?.refreshToken; + const expiresIn = numberField(response.json, "expires_in"); + + if (!accessToken || !refreshToken || !expiresIn) { + return Effect.fail( + connectionError( + "invalid_response", + "Microsoft identity platform returned an incomplete token response.", + ), + ); + } + + return Effect.succeed({ + accessToken, + refreshToken, + grantedScopes: splitScopes( + stringField(response.json, "scope"), + scopesForAccessLevel(accessLevel), + ), + expiresAtEpochMs: nowEpochMs + expiresIn * 1000, + }); +} + +function parseAccount( + response: JsonHttpResponse, +): Effect.Effect { + if (!response.ok) { + return Effect.fail( + connectionError("graph_error", "Microsoft Graph could not read the signed-in profile."), + ); + } + + return Effect.succeed({ + id: nullableStringField(response.json, "id"), + displayName: nullableStringField(response.json, "displayName"), + mail: nullableStringField(response.json, "mail"), + userPrincipalName: nullableStringField(response.json, "userPrincipalName"), + }); +} + +export const httpClientLayerLive = Layer.effect( + MicrosoftGraphHttpClient, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return MicrosoftGraphHttpClient.of({ + requestJson: (request) => + Effect.gen(function* () { + let httpRequest = + request.method === "POST" + ? HttpClientRequest.post(request.url) + : HttpClientRequest.get(request.url); + for (const [key, value] of Object.entries(request.headers ?? {})) { + httpRequest = HttpClientRequest.setHeader(key, value)(httpRequest); + } + if (request.form) { + httpRequest = HttpClientRequest.bodyUrlParams(request.form)(httpRequest); + } else if (request.json !== undefined) { + httpRequest = yield* HttpClientRequest.bodyJson(request.json)(httpRequest); + } + const response = yield* httpClient.execute(httpRequest); + const json = yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response); + return { + status: response.status, + ok: response.status >= 200 && response.status < 300, + json, + }; + }).pipe( + Effect.mapError((cause) => + connectionError("invalid_response", "Microsoft Graph HTTP request failed.", cause), + ), + ), + }); + }), +); + +export const make = Effect.fn("makeMicrosoftGraphConnection")(function* () { + const crypto = yield* Crypto.Crypto; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const http = yield* MicrosoftGraphHttpClient; + const pendingFlows = new Map(); + let activeAccessToken: ActiveAccessToken | null = null; + + const nowEpochMs = () => Clock.currentTimeMillis; + + const readCredential = Effect.fn("MicrosoftGraphConnection.readCredential")(function* () { + const bytes = yield* secrets + .get(MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME) + .pipe( + Effect.mapError((cause) => + connectionError("storage_error", "Failed to read Microsoft Graph credential.", cause), + ), + ); + if (Option.isNone(bytes)) return null; + return yield* decodePersistedCredentialJson(textDecoder.decode(bytes.value)).pipe( + Effect.catch(() => + secrets.remove(MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME).pipe(Effect.ignore, Effect.as(null)), + ), + ); + }); + + const writeCredential = Effect.fn("MicrosoftGraphConnection.writeCredential")(function* ( + credential: PersistedCredential, + ) { + const encoded = yield* encodePersistedCredentialJson(credential).pipe( + Effect.mapError((cause) => + connectionError("storage_error", "Failed to encode Microsoft Graph credential.", cause), + ), + ); + yield* secrets + .set(MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME, textEncoder.encode(encoded)) + .pipe( + Effect.mapError((cause) => + connectionError("storage_error", "Failed to persist Microsoft Graph credential.", cause), + ), + ); + }); + + const getStatus = Effect.fn("MicrosoftGraphConnection.getStatus")(function* () { + return toStatus(yield* readCredential(), activeAccessToken); + }); + + const loadAccount = (accessToken: string) => + http + .requestJson({ + method: "GET", + url: `${GRAPH_BASE_URL}/v1.0/me?$select=id,displayName,mail,userPrincipalName`, + headers: { authorization: `Bearer ${accessToken}` }, + }) + .pipe(Effect.flatMap(parseAccount)); + + const persistTokenResponse = Effect.fn("MicrosoftGraphConnection.persistTokenResponse")( + function* ( + response: JsonHttpResponse, + accessLevel: MicrosoftGraphAccessLevel, + existing?: PersistedCredential, + ) { + const now = yield* nowEpochMs(); + const token = yield* parseTokenResponse(response, now, accessLevel, existing); + activeAccessToken = { + accessToken: token.accessToken, + expiresAtEpochMs: token.expiresAtEpochMs, + grantedScopes: token.grantedScopes, + }; + const account = existing?.account ?? (yield* loadAccount(token.accessToken)); + const credential: PersistedCredential = { + version: 1, + clientId: MicrosoftGraphClientId, + tenantId: MicrosoftGraphTenantId, + refreshToken: token.refreshToken, + accessLevel, + grantedScopes: [...token.grantedScopes], + account, + updatedAt: isoFromEpochMs(now), + }; + yield* writeCredential(credential); + return credential; + }, + ); + + const refreshAccessToken = Effect.fn("MicrosoftGraphConnection.refreshAccessToken")(function* ( + credential: PersistedCredential, + ) { + const scopeString = () => scopeStringForAccessLevel(credentialAccessLevel(credential)); + const response = yield* http.requestJson({ + method: "POST", + url: tokenEndpoint(), + form: { + client_id: MicrosoftGraphClientId, + grant_type: "refresh_token", + refresh_token: credential.refreshToken, + scope: scopeString(), + }, + }); + + if (!response.ok) { + return yield* connectionError( + "oauth_error", + `Microsoft identity platform could not refresh Graph access: ${oauthErrorDescription( + response, + )}`, + ); + } + + return yield* persistTokenResponse(response, credentialAccessLevel(credential), credential); + }); + + const ensureAccessToken = Effect.fn("MicrosoftGraphConnection.ensureAccessToken")(function* () { + const credential = yield* readCredential(); + if (!credential) { + return yield* connectionError( + "not_connected", + "Microsoft Graph is not connected. Sign in from Settings -> Connections first.", + ); + } + const now = yield* nowEpochMs(); + if ( + activeAccessToken !== null && + activeAccessToken.expiresAtEpochMs - ACCESS_TOKEN_REFRESH_SKEW_MS > now + ) { + return activeAccessToken.accessToken; + } + yield* refreshAccessToken(credential); + if (activeAccessToken === null) { + return yield* connectionError("oauth_error", "Microsoft Graph access token refresh failed."); + } + return activeAccessToken.accessToken; + }); + + const startSignIn = Effect.fn("MicrosoftGraphConnection.startSignIn")(function* ( + input: MicrosoftGraphStartSignInInput, + ) { + const accessLevel = input.accessLevel ?? "read_only"; + const [flowId, now] = yield* Effect.all([ + crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + connectionError( + "invalid_response", + "Failed to create Microsoft Graph sign-in flow.", + cause, + ), + ), + ), + nowEpochMs(), + ]); + const response = yield* http.requestJson({ + method: "POST", + url: deviceCodeEndpoint(), + form: { + client_id: MicrosoftGraphClientId, + scope: scopeStringForAccessLevel(accessLevel), + }, + }); + + if (!response.ok) { + return yield* connectionError( + "oauth_error", + `Microsoft identity platform could not start Graph sign-in: ${oauthErrorDescription( + response, + )}`, + ); + } + + const parsed = yield* parseDeviceCodeResponse(response, flowId, accessLevel, now); + pendingFlows.set(flowId, parsed.pending); + return parsed.result; + }); + + const pollSignIn = Effect.fn("MicrosoftGraphConnection.pollSignIn")(function* ( + input: MicrosoftGraphPollSignInInput, + ) { + const flow = pendingFlows.get(input.flowId); + if (!flow) { + return yield* connectionError( + "flow_not_found", + "Microsoft Graph sign-in flow was not found.", + ); + } + + const now = yield* nowEpochMs(); + if (flow.expiresAtEpochMs <= now) { + pendingFlows.delete(input.flowId); + return { + state: "expired", + status: yield* getStatus(), + retryAfterSeconds: null, + message: "Microsoft Graph sign-in expired. Start a new sign-in flow.", + } satisfies MicrosoftGraphPollSignInResult; + } + + const response = yield* http.requestJson({ + method: "POST", + url: tokenEndpoint(), + form: { + client_id: MicrosoftGraphClientId, + grant_type: DEVICE_CODE_GRANT, + device_code: flow.deviceCode, + }, + }); + + if (!response.ok) { + const error = oauthError(response); + if (error === "authorization_pending") { + return { + state: "pending", + status: yield* getStatus(), + retryAfterSeconds: flow.intervalSeconds, + message: "Waiting for Microsoft sign-in to finish.", + } satisfies MicrosoftGraphPollSignInResult; + } + if (error === "slow_down") { + const retryAfterSeconds = flow.intervalSeconds + 5; + pendingFlows.set(input.flowId, { ...flow, intervalSeconds: retryAfterSeconds }); + return { + state: "pending", + status: yield* getStatus(), + retryAfterSeconds, + message: "Microsoft asked us to slow down sign-in polling.", + } satisfies MicrosoftGraphPollSignInResult; + } + if (error === "expired_token") { + pendingFlows.delete(input.flowId); + return { + state: "expired", + status: yield* getStatus(), + retryAfterSeconds: null, + message: "Microsoft Graph sign-in expired. Start a new sign-in flow.", + } satisfies MicrosoftGraphPollSignInResult; + } + if (error === "authorization_declined" || error === "bad_verification_code") { + pendingFlows.delete(input.flowId); + return { + state: "failed", + status: yield* getStatus(), + retryAfterSeconds: null, + message: + error === "authorization_declined" + ? "Microsoft Graph sign-in was declined." + : "Microsoft Graph sign-in code was rejected. Start a new sign-in flow.", + } satisfies MicrosoftGraphPollSignInResult; + } + + return yield* connectionError( + "oauth_error", + `Microsoft identity platform could not finish Graph sign-in: ${oauthErrorDescription( + response, + )}`, + ); + } + + const credential = yield* persistTokenResponse(response, flow.accessLevel); + pendingFlows.delete(input.flowId); + return { + state: "connected", + status: toStatus(credential, activeAccessToken), + retryAfterSeconds: null, + message: "Microsoft Graph is connected.", + } satisfies MicrosoftGraphPollSignInResult; + }); + + const disconnect = Effect.fn("MicrosoftGraphConnection.disconnect")(function* () { + yield* secrets + .remove(MICROSOFT_GRAPH_CREDENTIAL_SECRET_NAME) + .pipe( + Effect.mapError((cause) => + connectionError("storage_error", "Failed to remove Microsoft Graph credential.", cause), + ), + ); + activeAccessToken = null; + pendingFlows.clear(); + return { + status: toStatus(null, null), + } satisfies MicrosoftGraphDisconnectResult; + }); + + const requestGraphJson = Effect.fn("MicrosoftGraphConnection.requestGraphJson")(function* ( + input: MicrosoftGraphRequestJsonInput, + ) { + if (!input.path.startsWith("/") || input.path.startsWith("//")) { + return yield* connectionError("graph_error", "Graph requests must use root-relative paths."); + } + const url = new URL(input.path, `${GRAPH_BASE_URL}/`); + if (url.origin !== GRAPH_BASE_URL) { + return yield* connectionError("graph_error", "Graph requests must target Microsoft Graph."); + } + + const accessToken = yield* ensureAccessToken(); + const response = yield* http.requestJson({ + method: input.method ?? "GET", + url: url.toString(), + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return yield* connectionError( + "graph_error", + `Microsoft Graph request failed with HTTP ${response.status}.`, + ); + } + return response.json; + }); + + return MicrosoftGraphConnection.of({ + getStatus: () => exposeConnectionEffect(getStatus()), + startSignIn: (input) => exposeConnectionEffect(startSignIn(input)), + pollSignIn: (input) => exposeConnectionEffect(pollSignIn(input)), + disconnect: () => exposeConnectionEffect(disconnect()), + requestGraphJson: (input) => exposeConnectionEffect(requestGraphJson(input)), + }); +}); + +export const layer = Layer.effect(MicrosoftGraphConnection, make()); + +export const layerLive = layer.pipe(Layer.provide(httpClientLayerLive)); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8ecfc08aadd..9355ea29eb4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -15,6 +15,10 @@ import { GitCommandError, KeybindingRule, MessageId, + MicrosoftGraphClientId, + type MicrosoftGraphConnectionStatus, + MicrosoftGraphReadOnlyScopes, + MicrosoftGraphTenantId, ExternalLauncherCommandNotFoundError, type OrchestrationThreadShell, TerminalNotRunningError, @@ -110,6 +114,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; +import * as MicrosoftGraphConnection from "./microsoftGraph/MicrosoftGraphConnection.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -122,6 +127,17 @@ const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; +const defaultMicrosoftGraphStatus = (): MicrosoftGraphConnectionStatus => ({ + state: "not_connected", + accessLevel: null, + account: null, + clientId: MicrosoftGraphClientId, + tenantId: MicrosoftGraphTenantId, + requiredScopes: [...MicrosoftGraphReadOnlyScopes], + grantedScopes: [], + accessTokenExpiresAt: null, + updatedAt: null, +}); const testEnvironmentDescriptor = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -349,6 +365,9 @@ const buildAppUnderTest = (options?: { >; relayClient?: Partial; cloudCliTokenManager?: Partial; + microsoftGraphConnection?: Partial< + MicrosoftGraphConnection.MicrosoftGraphConnection["Service"] + >; }; }) => Effect.gen(function* () { @@ -800,6 +819,23 @@ const buildAppUnderTest = (options?: { ...options?.layers?.cloudCliTokenManager, }), ), + Layer.provide( + Layer.succeed( + MicrosoftGraphConnection.MicrosoftGraphConnection, + MicrosoftGraphConnection.MicrosoftGraphConnection.of({ + getStatus: () => Effect.succeed(defaultMicrosoftGraphStatus()), + startSignIn: () => Effect.die(new Error("Unexpected Microsoft Graph sign-in.")), + pollSignIn: () => Effect.die(new Error("Unexpected Microsoft Graph sign-in poll.")), + disconnect: () => + Effect.succeed({ + status: defaultMicrosoftGraphStatus(), + }), + requestGraphJson: () => + Effect.die(new Error("Unexpected Microsoft Graph request in server route test.")), + ...options?.layers?.microsoftGraphConnection, + }), + ), + ), Layer.provideMerge(makeAuthTestLayer()), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..4bf892ebca4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -82,6 +82,7 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as MicrosoftGraphConnection from "./microsoftGraph/MicrosoftGraphConnection.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -279,6 +280,10 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll( ), ); +const MicrosoftGraphConnectionLayerLive = MicrosoftGraphConnection.layerLive.pipe( + Layer.provide(ServerSecretStore.layer), +); + const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive), @@ -479,6 +484,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(MicrosoftGraphConnectionLayerLive), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0c86f65852c..b8ba77b7278 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -17,6 +17,7 @@ import { AuthRelayWriteScope, AuthTerminalOperateScope, AuthAccessReadScope, + AuthAccessWriteScope, AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -27,6 +28,7 @@ import { type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, + type MicrosoftGraphConnectionStatus, OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, @@ -112,6 +114,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; +import * as MicrosoftGraphConnection from "./microsoftGraph/MicrosoftGraphConnection.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -122,6 +125,22 @@ function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } +function redactMicrosoftGraphStatusForWriteOnly( + status: MicrosoftGraphConnectionStatus, +): MicrosoftGraphConnectionStatus { + return { + state: status.state, + accessLevel: null, + account: null, + clientId: status.clientId, + tenantId: status.tenantId, + requiredScopes: status.requiredScopes, + grantedScopes: [], + accessTokenExpiresAt: null, + updatedAt: null, + }; +} + /** Preserve the setup runner's broader pre-refactor message normalization. */ function legacySetupFailureDescription(cause: unknown): string { if ( @@ -309,6 +328,10 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverUpgradeMarketplace, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], + [WS_METHODS.microsoftGraphGetStatus, AuthAccessReadScope], + [WS_METHODS.microsoftGraphStartSignIn, AuthAccessWriteScope], + [WS_METHODS.microsoftGraphPollSignIn, AuthAccessWriteScope], + [WS_METHODS.microsoftGraphDisconnect, AuthAccessWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], @@ -447,6 +470,7 @@ const makeWsRpcLayer = ( const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const microsoftGraph = yield* MicrosoftGraphConnection.MicrosoftGraphConnection; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1389,6 +1413,37 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "cloud" }, ), + [WS_METHODS.microsoftGraphGetStatus]: (_input) => + observeRpcEffect(WS_METHODS.microsoftGraphGetStatus, microsoftGraph.getStatus(), { + "rpc.aggregate": "microsoft-graph", + }), + [WS_METHODS.microsoftGraphStartSignIn]: (input) => + observeRpcEffect( + WS_METHODS.microsoftGraphStartSignIn, + microsoftGraph.startSignIn(input), + { + "rpc.aggregate": "microsoft-graph", + }, + ), + [WS_METHODS.microsoftGraphPollSignIn]: (input) => + observeRpcEffect( + WS_METHODS.microsoftGraphPollSignIn, + microsoftGraph.pollSignIn(input).pipe( + Effect.map((result) => + currentSession.scopes.includes(AuthAccessReadScope) + ? result + : { + ...result, + status: redactMicrosoftGraphStatusForWriteOnly(result.status), + }, + ), + ), + { "rpc.aggregate": "microsoft-graph" }, + ), + [WS_METHODS.microsoftGraphDisconnect]: (_input) => + observeRpcEffect(WS_METHODS.microsoftGraphDisconnect, microsoftGraph.disconnect(), { + "rpc.aggregate": "microsoft-graph", + }), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 9c838c31f9a..2b01138739b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,11 +1,16 @@ import { ChevronDownIcon, ChevronsLeftRightEllipsisIcon, + CopyIcon, + ExternalLinkIcon, + LogInIcon, + MailIcon, PlusIcon, QrCodeIcon, RefreshCwIcon, TerminalIcon, TriangleAlertIcon, + UnplugIcon, } from "lucide-react"; import { useAuth } from "@clerk/react"; import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; @@ -29,6 +34,9 @@ import { type DesktopServerExposureState, type DesktopWslState, type EnvironmentId, + type MicrosoftGraphAccessLevel, + type MicrosoftGraphConnectionStatus, + type MicrosoftGraphStartSignInResult, } from "@t3tools/contracts"; import { connectionStatusText, @@ -59,6 +67,7 @@ import { } from "./settingsLayout"; import { Input } from "../ui/input"; import { Checkbox } from "../ui/checkbox"; +import { Radio, RadioGroup } from "../ui/radio-group"; import { Dialog, DialogClose, @@ -126,6 +135,8 @@ import { } from "~/cloud/linkEnvironmentAtoms"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; +import { ensureLocalApi } from "~/localApi"; +import { serverEnvironment } from "~/state/server"; import { connectPairing as connectPairingAtom, connectSshEnvironment as connectSshEnvironmentAtom, @@ -169,6 +180,19 @@ function formatAccessTimestamp(value: string): string { return accessTimestampFormatter.format(parsed); } +function microsoftGraphAccountLabel(status: MicrosoftGraphConnectionStatus | null): string | null { + const account = status?.account; + return account?.mail ?? account?.userPrincipalName ?? account?.displayName ?? null; +} + +function microsoftGraphAccessLevelLabel(accessLevel: MicrosoftGraphAccessLevel): string { + return accessLevel === "full" ? "Full access" : "Read only"; +} + +function microsoftGraphErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ readonly scope: AuthEnvironmentScope; readonly title: string; @@ -2148,7 +2172,58 @@ export function ConnectionsSettings() { (state) => state.setDefaultAdvertisedEndpointKey, ); const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canReadMicrosoftGraph = currentSessionScopes?.includes(AuthAccessReadScope) ?? false; + const canManageMicrosoftGraph = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + const loadMicrosoftGraphStatusCommand = useAtomCommand( + serverEnvironment.microsoftGraphGetStatus, + { reportFailure: false }, + ); + const startMicrosoftGraphSignInCommand = useAtomCommand( + serverEnvironment.microsoftGraphStartSignIn, + { reportFailure: false }, + ); + const pollMicrosoftGraphSignInCommand = useAtomCommand( + serverEnvironment.microsoftGraphPollSignIn, + { reportFailure: false }, + ); + const disconnectMicrosoftGraphCommand = useAtomCommand( + serverEnvironment.microsoftGraphDisconnect, + { reportFailure: false }, + ); + const [microsoftGraphStatus, setMicrosoftGraphStatus] = + useState(null); + const [microsoftGraphSignInFlow, setMicrosoftGraphSignInFlow] = + useState(null); + const [isMicrosoftGraphAccessDialogOpen, setIsMicrosoftGraphAccessDialogOpen] = useState(false); + const [microsoftGraphSelectedAccessLevel, setMicrosoftGraphSelectedAccessLevel] = + useState("read_only"); + const [microsoftGraphError, setMicrosoftGraphError] = useState(null); + const [isLoadingMicrosoftGraph, setIsLoadingMicrosoftGraph] = useState(false); + const [isStartingMicrosoftGraphSignIn, setIsStartingMicrosoftGraphSignIn] = useState(false); + const [isPollingMicrosoftGraphSignIn, setIsPollingMicrosoftGraphSignIn] = useState(false); + const [isDisconnectingMicrosoftGraph, setIsDisconnectingMicrosoftGraph] = useState(false); + const microsoftGraphNowMs = useRelativeTimeTick(1_000); + const { copyToClipboard: copyMicrosoftGraphUserCode } = + useCopyToClipboard<"microsoft-graph-user-code">({ + target: "Microsoft sign-in code", + onCopy: () => { + toastManager.add({ + type: "success", + title: "Code copied", + description: "Paste it into the Microsoft sign-in page.", + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy code", + description: error.message, + }), + ); + }, + }); const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null ? authEnvironment.accessChanges({ @@ -2342,6 +2417,137 @@ export function ConnectionsSettings() { setDisableTailscaleServeDialogOpen(true); }, []); + const loadMicrosoftGraphStatus = useCallback(async () => { + if (!primaryEnvironmentId || !canReadMicrosoftGraph) { + setMicrosoftGraphStatus(null); + setMicrosoftGraphError(null); + return; + } + + setIsLoadingMicrosoftGraph(true); + setMicrosoftGraphError(null); + const result = await loadMicrosoftGraphStatusCommand({ + environmentId: primaryEnvironmentId, + input: {}, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setMicrosoftGraphError( + microsoftGraphErrorMessage(error, "Failed to load Microsoft Graph connection."), + ); + } + setIsLoadingMicrosoftGraph(false); + return; + } + + setMicrosoftGraphStatus(result.value); + setIsLoadingMicrosoftGraph(false); + }, [canReadMicrosoftGraph, loadMicrosoftGraphStatusCommand, primaryEnvironmentId]); + + const openMicrosoftGraphSignIn = useCallback(async (flow: MicrosoftGraphStartSignInResult) => { + const signInUrl = flow.verificationUriComplete ?? flow.verificationUri; + try { + await ensureLocalApi().shell.openExternal(signInUrl); + toastManager.add({ + type: "info", + title: "Microsoft sign-in opened", + description: "Finish sign-in in the browser, then return here.", + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Open Microsoft sign-in manually", + description: microsoftGraphErrorMessage( + error, + "Use the visible sign-in button and code to finish connection.", + ), + }), + ); + } + }, []); + + const handleStartMicrosoftGraphSignIn = useCallback( + async (accessLevel: MicrosoftGraphAccessLevel) => { + if (!primaryEnvironmentId || !canManageMicrosoftGraph) return; + + setIsStartingMicrosoftGraphSignIn(true); + setMicrosoftGraphError(null); + const result = await startMicrosoftGraphSignInCommand({ + environmentId: primaryEnvironmentId, + input: { accessLevel }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + const message = microsoftGraphErrorMessage( + error, + "Failed to start Microsoft Graph sign-in.", + ); + setMicrosoftGraphError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start Microsoft sign-in", + description: message, + }), + ); + } + setIsStartingMicrosoftGraphSignIn(false); + return; + } + + setMicrosoftGraphSignInFlow(result.value); + setIsMicrosoftGraphAccessDialogOpen(false); + setMicrosoftGraphSelectedAccessLevel("read_only"); + await openMicrosoftGraphSignIn(result.value); + setIsStartingMicrosoftGraphSignIn(false); + }, + [ + canManageMicrosoftGraph, + openMicrosoftGraphSignIn, + primaryEnvironmentId, + startMicrosoftGraphSignInCommand, + ], + ); + + const handleDisconnectMicrosoftGraph = useCallback(async () => { + if (!primaryEnvironmentId || !canManageMicrosoftGraph) return; + + setIsDisconnectingMicrosoftGraph(true); + setMicrosoftGraphError(null); + const result = await disconnectMicrosoftGraphCommand({ + environmentId: primaryEnvironmentId, + input: {}, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + const message = microsoftGraphErrorMessage(error, "Failed to disconnect Microsoft Graph."); + setMicrosoftGraphError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect Microsoft Graph", + description: message, + }), + ); + } + setIsDisconnectingMicrosoftGraph(false); + return; + } + + setMicrosoftGraphStatus(result.value.status); + setMicrosoftGraphSignInFlow(null); + toastManager.add({ + type: "success", + title: "Microsoft Graph disconnected", + description: "The saved server-side Microsoft credential was removed.", + }); + setIsDisconnectingMicrosoftGraph(false); + }, [canManageMicrosoftGraph, disconnectMicrosoftGraphCommand, primaryEnvironmentId]); + const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { setRevokingDesktopPairingLinkId(id); setDesktopAccessManagementMutationError(null); @@ -2556,6 +2762,101 @@ export function ConnectionsSettings() { [removeEnvironment], ); + useEffect(() => { + void loadMicrosoftGraphStatus(); + }, [loadMicrosoftGraphStatus]); + + useEffect(() => { + if (!microsoftGraphSignInFlow || !primaryEnvironmentId || !canManageMicrosoftGraph) return; + + const activeEnvironmentId = primaryEnvironmentId; + const activeFlow = microsoftGraphSignInFlow; + let cancelled = false; + let timeoutId: ReturnType | undefined; + + function schedulePoll(delaySeconds: number) { + timeoutId = setTimeout( + () => { + void poll(); + }, + Math.max(1, delaySeconds) * 1000, + ); + } + + async function poll() { + if (cancelled) return; + setIsPollingMicrosoftGraphSignIn(true); + const result = await pollMicrosoftGraphSignInCommand({ + environmentId: activeEnvironmentId, + input: { flowId: activeFlow.flowId }, + }); + if (cancelled) return; + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + const message = microsoftGraphErrorMessage( + error, + "Failed to finish Microsoft Graph sign-in.", + ); + setMicrosoftGraphError(message); + setMicrosoftGraphSignInFlow(null); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not finish Microsoft sign-in", + description: message, + }), + ); + } + setIsPollingMicrosoftGraphSignIn(false); + return; + } + + setMicrosoftGraphStatus(result.value.status); + setMicrosoftGraphError(null); + + if (result.value.state === "pending") { + setIsPollingMicrosoftGraphSignIn(false); + schedulePoll(result.value.retryAfterSeconds ?? activeFlow.intervalSeconds); + return; + } + + setMicrosoftGraphSignInFlow(null); + setIsPollingMicrosoftGraphSignIn(false); + if (result.value.state === "connected") { + toastManager.add({ + type: "success", + title: "Microsoft Graph connected", + description: "Outlook mail and calendar access is ready for server-side features.", + }); + return; + } + + const message = result.value.message ?? "Microsoft Graph sign-in did not complete."; + setMicrosoftGraphError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: result.value.state === "expired" ? "Microsoft sign-in expired" : "Sign-in failed", + description: message, + }), + ); + } + + schedulePoll(activeFlow.intervalSeconds); + + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [ + canManageMicrosoftGraph, + microsoftGraphSignInFlow, + pollMicrosoftGraphSignInCommand, + primaryEnvironmentId, + ]); + const handleConnectSshHost = useCallback( async (target: DesktopSshEnvironmentTarget, label?: string) => { setConnectingSshHostAlias(target.alias); @@ -3197,6 +3498,315 @@ export function ConnectionsSettings() { } /> ); + + const renderMicrosoftGraphSection = () => { + const isConnected = microsoftGraphStatus?.state === "connected"; + const accountLabel = microsoftGraphAccountLabel(microsoftGraphStatus); + const clientId = microsoftGraphStatus?.clientId ?? microsoftGraphSignInFlow?.clientId ?? null; + const tenantId = microsoftGraphStatus?.tenantId ?? microsoftGraphSignInFlow?.tenantId ?? null; + const requiredScopes = + microsoftGraphStatus?.requiredScopes ?? microsoftGraphSignInFlow?.requiredScopes ?? []; + const grantedScopes = microsoftGraphStatus?.grantedScopes ?? []; + const accessLevel = + microsoftGraphStatus?.accessLevel ?? microsoftGraphSignInFlow?.accessLevel ?? null; + const accessLevelLabel = accessLevel + ? `Access: ${microsoftGraphAccessLevelLabel(accessLevel)}` + : null; + const statusDescription = !canReadMicrosoftGraph + ? "Requires access:read to inspect and access:write to connect." + : isLoadingMicrosoftGraph && microsoftGraphStatus === null + ? "Loading connection status." + : microsoftGraphSignInFlow + ? "Waiting for Microsoft sign-in to finish." + : isConnected + ? accountLabel + ? `Connected as ${accountLabel}.` + : "Connected to Microsoft Graph." + : "Not connected."; + const statusDot = microsoftGraphError + ? "error" + : microsoftGraphSignInFlow + ? "pending" + : isConnected + ? "connected" + : "idle"; + const statusText = + statusDot === "connected" + ? "Microsoft Graph connected" + : statusDot === "pending" + ? "Microsoft Graph sign-in pending" + : statusDot === "error" + ? "Microsoft Graph connection error" + : "Microsoft Graph not connected"; + const scopeLabel = + grantedScopes.length > 0 + ? `Granted: ${grantedScopes.join(", ")}` + : `Required: ${requiredScopes.join(", ")}`; + const tokenExpiryLabel = microsoftGraphStatus?.accessTokenExpiresAt + ? `Access expires ${formatExpiresInLabel( + microsoftGraphStatus.accessTokenExpiresAt, + microsoftGraphNowMs, + )}` + : null; + const updatedAtLabel = microsoftGraphStatus?.updatedAt + ? `Updated ${formatAccessTimestamp(microsoftGraphStatus.updatedAt)}` + : null; + + return ( + }> + + + Outlook and calendar + + } + description={statusDescription} + status={ +
+ {microsoftGraphError ? ( + {microsoftGraphError} + ) : null} + {canReadMicrosoftGraph && microsoftGraphStatus ? ( + + {[accessLevelLabel, scopeLabel, tokenExpiryLabel, updatedAtLabel] + .filter(Boolean) + .join(" · ")} + + ) : null} +
+ } + control={ + isConnected ? ( + + ) : ( + { + if (isStartingMicrosoftGraphSignIn) return; + setIsMicrosoftGraphAccessDialogOpen(open); + if (!open) setMicrosoftGraphSelectedAccessLevel("read_only"); + }} + > + + {isStartingMicrosoftGraphSignIn ? ( + + ) : ( + + )} + Sign in + + } + /> + + + Choose Microsoft Graph access + + Choose what TritonAI Harness can do with your Outlook mail and calendar. You + can disconnect and choose a different level later. + + + + { + if (value === "read_only" || value === "full") { + setMicrosoftGraphSelectedAccessLevel(value); + } + }} + aria-label="Microsoft Graph access level" + className="gap-2" + > + + + + + + + + + + + ) + } + > + {microsoftGraphSignInFlow ? ( +
+
+
+

+ Microsoft code +

+

+ {microsoftGraphSignInFlow.userCode} +

+

+ Expires{" "} + {formatExpiresInLabel(microsoftGraphSignInFlow.expiresAt, microsoftGraphNowMs)} +

+
+
+ + + copyMicrosoftGraphUserCode( + microsoftGraphSignInFlow.userCode, + "microsoft-graph-user-code", + ) + } + > + + + } + /> + Copy code + + + void openMicrosoftGraphSignIn(microsoftGraphSignInFlow)} + > + + + } + /> + Open sign-in + +
+
+
+ ) : null} + + {clientId && tenantId ? ( +
+
+ Client ID + + {clientId} + +
+
+ Tenant ID + + {tenantId} + +
+
+ Delegated scopes + + {requiredScopes.join(", ")} + +
+
+ ) : null} +
+
+ ); + }; + const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( <> {desktopAccessManagementError ? ( @@ -3611,6 +4221,8 @@ export function ConnectionsSettings() { )} + {renderMicrosoftGraphSection()} + ( label: "environment-data:server:upgrade-marketplace", tag: WS_METHODS.serverUpgradeMarketplace, }), + microsoftGraphGetStatus: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:microsoft-graph-get-status", + tag: WS_METHODS.microsoftGraphGetStatus, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + microsoftGraphStartSignIn: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:microsoft-graph-start-sign-in", + tag: WS_METHODS.microsoftGraphStartSignIn, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + microsoftGraphPollSignIn: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:microsoft-graph-poll-sign-in", + tag: WS_METHODS.microsoftGraphPollSignIn, + concurrency: { + mode: "latest", + key: ({ environmentId, input }) => `${environmentId}:${input.flowId}`, + }, + }), + microsoftGraphDisconnect: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:microsoft-graph-disconnect", + tag: WS_METHODS.microsoftGraphDisconnect, + scheduler: configScheduler, + concurrency: configConcurrency, + }), }; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 29b547269b4..369edc9a658 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -20,6 +20,7 @@ export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; export * from "./orchestration.ts"; +export * from "./microsoftGraph.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; diff --git a/packages/contracts/src/microsoftGraph.ts b/packages/contracts/src/microsoftGraph.ts new file mode 100644 index 00000000000..29852df609d --- /dev/null +++ b/packages/contracts/src/microsoftGraph.ts @@ -0,0 +1,109 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const MicrosoftGraphClientId = "fcfe0e23-a675-4851-99a7-704dfd153b9c" as const; +export const MicrosoftGraphTenantId = "8a198873-4fec-4e76-8182-ca479edbbd60" as const; +export const MicrosoftGraphReadOnlyScopes = [ + "User.Read", + "Mail.Read", + "Calendars.Read", + "offline_access", +] as const; +export const MicrosoftGraphFullAccessScopes = [ + "User.Read", + "Mail.ReadWrite", + "Mail.Send", + "Calendars.ReadWrite", + "offline_access", +] as const; + +export const MicrosoftGraphAccessLevel = Schema.Literals(["read_only", "full"]); +export type MicrosoftGraphAccessLevel = typeof MicrosoftGraphAccessLevel.Type; + +export const MicrosoftGraphConnectionState = Schema.Literals(["not_connected", "connected"]); +export type MicrosoftGraphConnectionState = typeof MicrosoftGraphConnectionState.Type; + +export const MicrosoftGraphSignInPollState = Schema.Literals([ + "pending", + "connected", + "expired", + "failed", +]); +export type MicrosoftGraphSignInPollState = typeof MicrosoftGraphSignInPollState.Type; + +export const MicrosoftGraphAccount = Schema.Struct({ + id: Schema.NullOr(TrimmedNonEmptyString), + displayName: Schema.NullOr(TrimmedNonEmptyString), + mail: Schema.NullOr(TrimmedNonEmptyString), + userPrincipalName: Schema.NullOr(TrimmedNonEmptyString), +}); +export type MicrosoftGraphAccount = typeof MicrosoftGraphAccount.Type; + +export const MicrosoftGraphConnectionStatus = Schema.Struct({ + state: MicrosoftGraphConnectionState, + accessLevel: Schema.NullOr(MicrosoftGraphAccessLevel), + account: Schema.NullOr(MicrosoftGraphAccount), + clientId: Schema.Literal(MicrosoftGraphClientId), + tenantId: Schema.Literal(MicrosoftGraphTenantId), + requiredScopes: Schema.Array(TrimmedNonEmptyString), + grantedScopes: Schema.Array(TrimmedNonEmptyString), + accessTokenExpiresAt: Schema.NullOr(IsoDateTime), + updatedAt: Schema.NullOr(IsoDateTime), +}); +export type MicrosoftGraphConnectionStatus = typeof MicrosoftGraphConnectionStatus.Type; + +export const MicrosoftGraphStartSignInInput = Schema.Struct({ + accessLevel: Schema.optional(MicrosoftGraphAccessLevel), +}); +export type MicrosoftGraphStartSignInInput = typeof MicrosoftGraphStartSignInInput.Type; + +export const MicrosoftGraphStartSignInResult = Schema.Struct({ + accessLevel: MicrosoftGraphAccessLevel, + flowId: TrimmedNonEmptyString, + verificationUri: TrimmedNonEmptyString, + verificationUriComplete: Schema.NullOr(TrimmedNonEmptyString), + userCode: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, + expiresAt: IsoDateTime, + intervalSeconds: PositiveInt, + clientId: Schema.Literal(MicrosoftGraphClientId), + tenantId: Schema.Literal(MicrosoftGraphTenantId), + requiredScopes: Schema.Array(TrimmedNonEmptyString), +}); +export type MicrosoftGraphStartSignInResult = typeof MicrosoftGraphStartSignInResult.Type; + +export const MicrosoftGraphPollSignInInput = Schema.Struct({ + flowId: TrimmedNonEmptyString, +}); +export type MicrosoftGraphPollSignInInput = typeof MicrosoftGraphPollSignInInput.Type; + +export const MicrosoftGraphPollSignInResult = Schema.Struct({ + state: MicrosoftGraphSignInPollState, + status: MicrosoftGraphConnectionStatus, + retryAfterSeconds: Schema.NullOr(PositiveInt), + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type MicrosoftGraphPollSignInResult = typeof MicrosoftGraphPollSignInResult.Type; + +export const MicrosoftGraphDisconnectResult = Schema.Struct({ + status: MicrosoftGraphConnectionStatus, +}); +export type MicrosoftGraphDisconnectResult = typeof MicrosoftGraphDisconnectResult.Type; + +export class MicrosoftGraphConnectionError extends Schema.TaggedErrorClass()( + "MicrosoftGraphConnectionError", + { + message: TrimmedNonEmptyString, + code: Schema.Literals([ + "not_connected", + "flow_not_found", + "flow_expired", + "oauth_error", + "graph_error", + "storage_error", + "invalid_response", + ]), + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 40a75d9c072..eff3d5d971d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -64,6 +64,15 @@ import { RelayClientInstallProgressEventSchema, RelayClientStatusSchema, } from "./relayClient.ts"; +import { + MicrosoftGraphConnectionError, + MicrosoftGraphConnectionStatus, + MicrosoftGraphDisconnectResult, + MicrosoftGraphPollSignInInput, + MicrosoftGraphPollSignInResult, + MicrosoftGraphStartSignInInput, + MicrosoftGraphStartSignInResult, +} from "./microsoftGraph.ts"; import { ProjectListEntriesError, ProjectListEntriesInput, @@ -251,6 +260,12 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Microsoft Graph connection methods + microsoftGraphGetStatus: "microsoftGraph.getStatus", + microsoftGraphStartSignIn: "microsoftGraph.startSignIn", + microsoftGraphPollSignIn: "microsoftGraph.pollSignIn", + microsoftGraphDisconnect: "microsoftGraph.disconnect", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -439,6 +454,30 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela stream: true, }); +export const WsMicrosoftGraphGetStatusRpc = Rpc.make(WS_METHODS.microsoftGraphGetStatus, { + payload: Schema.Struct({}), + success: MicrosoftGraphConnectionStatus, + error: Schema.Union([MicrosoftGraphConnectionError, EnvironmentAuthorizationError]), +}); + +export const WsMicrosoftGraphStartSignInRpc = Rpc.make(WS_METHODS.microsoftGraphStartSignIn, { + payload: MicrosoftGraphStartSignInInput, + success: MicrosoftGraphStartSignInResult, + error: Schema.Union([MicrosoftGraphConnectionError, EnvironmentAuthorizationError]), +}); + +export const WsMicrosoftGraphPollSignInRpc = Rpc.make(WS_METHODS.microsoftGraphPollSignIn, { + payload: MicrosoftGraphPollSignInInput, + success: MicrosoftGraphPollSignInResult, + error: Schema.Union([MicrosoftGraphConnectionError, EnvironmentAuthorizationError]), +}); + +export const WsMicrosoftGraphDisconnectRpc = Rpc.make(WS_METHODS.microsoftGraphDisconnect, { + payload: Schema.Struct({}), + success: MicrosoftGraphDisconnectResult, + error: Schema.Union([MicrosoftGraphConnectionError, EnvironmentAuthorizationError]), +}); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -816,6 +855,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpgradeMarketplaceRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsMicrosoftGraphGetStatusRpc, + WsMicrosoftGraphStartSignInRpc, + WsMicrosoftGraphPollSignInRpc, + WsMicrosoftGraphDisconnectRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc,