diff --git a/apps/gateway/src/apps.rs b/apps/gateway/src/apps.rs index 3b877bc..7fc34f6 100644 --- a/apps/gateway/src/apps.rs +++ b/apps/gateway/src/apps.rs @@ -43,7 +43,15 @@ pub(crate) enum ClientAuthMethod { /// Configuration for refreshing expired OAuth tokens. pub(crate) struct RefreshConfig { /// Token endpoint URL (e.g., `https://oauth2.googleapis.com/token`). + /// May contain `{tenant}` placeholder — see `tenant_settings_key`. pub token_url: &'static str, + /// When set, `token_url` is treated as a template: `{tenant}` is replaced + /// with the value of this key from `AppConfig.settings`. Falls back to + /// `tenant_default` if the key is absent. + pub tenant_settings_key: Option<&'static str>, + /// Default tenant value when `tenant_settings_key` is set but the key is + /// missing from AppConfig settings. + pub tenant_default: Option<&'static str>, /// Env var for the OAuth client ID. pub client_id_env: &'static str, /// Env var for the OAuth client secret. @@ -89,6 +97,8 @@ struct DynamicAppProvider { /// Shared refresh config for all Google OAuth APIs. static GOOGLE_REFRESH: RefreshConfig = RefreshConfig { token_url: "https://oauth2.googleapis.com/token", + tenant_settings_key: None, + tenant_default: None, client_id_env: "GOOGLE_CLIENT_ID", client_secret_env: "GOOGLE_CLIENT_SECRET", client_auth: ClientAuthMethod::Body, @@ -98,11 +108,24 @@ static GOOGLE_REFRESH: RefreshConfig = RefreshConfig { /// Spotify's `/api/token` endpoint requires HTTP Basic auth for client credentials. static SPOTIFY_REFRESH: RefreshConfig = RefreshConfig { token_url: "https://accounts.spotify.com/api/token", + tenant_settings_key: None, + tenant_default: None, client_id_env: "SPOTIFY_CLIENT_ID", client_secret_env: "SPOTIFY_CLIENT_SECRET", client_auth: ClientAuthMethod::Basic, }; +/// Refresh config for Microsoft Graph (Microsoft identity platform v2.0). +/// The token URL contains a `{tenant}` placeholder resolved from AppConfig settings. +static MICROSOFT_REFRESH: RefreshConfig = RefreshConfig { + token_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + tenant_settings_key: Some("tenantId"), + tenant_default: Some("common"), + client_id_env: "MICROSOFT_GRAPH_CLIENT_ID", + client_secret_env: "MICROSOFT_GRAPH_CLIENT_SECRET", + client_auth: ClientAuthMethod::Body, +}; + // ── Provider registry ────────────────────────────────────────────────── static APP_PROVIDERS: &[AppProvider] = &[ @@ -331,6 +354,16 @@ static APP_PROVIDERS: &[AppProvider] = &[ }], refresh: Some(&SPOTIFY_REFRESH), }, + AppProvider { + provider: "microsoft-graph", + display_name: "Microsoft Graph", + host_rules: &[HostRule { + host: "graph.microsoft.com", + path_prefix: None, + strategy: AuthStrategy::Bearer, + }], + refresh: Some(&MICROSOFT_REFRESH), + }, ]; // ── Dynamic-host provider registry ───────────────────────────────────── @@ -560,14 +593,42 @@ pub(crate) fn refresh_config(provider: &str) -> Option<&'static RefreshConfig> { .and_then(|p| p.refresh) } +/// Resolve the effective token URL for a refresh config. +/// +/// When `config.tenant_settings_key` is set, `{tenant}` in `config.token_url` +/// is replaced with the value from `settings[key]`, falling back to +/// `config.tenant_default`, then `"common"`. +/// +/// For configs without a tenant key (Google, Spotify), returns the static URL +/// unchanged. +pub(crate) fn resolve_token_url( + config: &RefreshConfig, + settings: Option<&serde_json::Value>, +) -> String { + match config.tenant_settings_key { + Some(key) => { + let tenant = settings + .and_then(|s| s.get(key)) + .and_then(|v| v.as_str()) + .or(config.tenant_default) + .unwrap_or("common"); + config.token_url.replace("{tenant}", tenant) + } + None => config.token_url.to_string(), + } +} + /// Refresh an expired access token using the provider's token endpoint. /// Returns the new access token and updated expires_at timestamp. /// +/// `effective_token_url` is the resolved URL (see [`resolve_token_url`]). +/// /// Client credentials are resolved in order: /// 1. Explicit `client_id`/`client_secret` (from BYOC AppConfig) /// 2. Env vars from `RefreshConfig` (platform defaults) pub(crate) async fn refresh_access_token( config: &RefreshConfig, + effective_token_url: &str, refresh_token: &str, byoc_client_id: Option<&str>, byoc_client_secret: Option<&str>, @@ -583,7 +644,7 @@ pub(crate) async fn refresh_access_token( .map_err(|_| anyhow::anyhow!("{} env var not set", config.client_secret_env))?, }; - let req = reqwest::Client::new().post(config.token_url); + let req = reqwest::Client::new().post(effective_token_url); let req = match config.client_auth { ClientAuthMethod::Body => req.form(&[ ("client_id", client_id.as_str()), diff --git a/apps/gateway/src/connect.rs b/apps/gateway/src/connect.rs index 604db1d..a062463 100644 --- a/apps/gateway/src/connect.rs +++ b/apps/gateway/src/connect.rs @@ -117,6 +117,15 @@ pub(crate) enum ConnectError { Internal(String), } +/// BYOC (bring-your-own-credentials) resolved from AppConfig. +/// Carries the full settings JSON so callers can read provider-specific +/// fields (e.g., `tenantId` for Microsoft Graph). +struct ByocCredentials { + client_id: String, + client_secret: String, + settings: Option, +} + // ── PolicyEngine ─────────────────────────────────────────────────── /// Resolves CONNECT policy by querying the database directly via SQLx @@ -488,13 +497,18 @@ impl PolicyEngine { if let Some(refresh_token) = creds.get("refresh_token").and_then(|v| v.as_str()) { if let Some(config) = apps::refresh_config(provider) { let byoc = self.resolve_byoc_credentials(account_id, provider).await; + let effective_token_url = apps::resolve_token_url( + config, + byoc.as_ref().and_then(|b| b.settings.as_ref()), + ); let (byoc_id, byoc_secret) = match &byoc { - Some((id, secret)) => (Some(id.as_str()), Some(secret.as_str())), + Some(b) => (Some(b.client_id.as_str()), Some(b.client_secret.as_str())), None => (None, None), }; match apps::refresh_access_token( config, + &effective_token_url, refresh_token, byoc_id, byoc_secret, @@ -555,13 +569,14 @@ impl PolicyEngine { } } - /// Resolve BYOC client credentials from AppConfig for a given account + provider. - /// Returns `Some((client_id, client_secret))` if an enabled config exists, `None` otherwise. + /// Resolve BYOC client credentials and settings from AppConfig for a given + /// account + provider. Returns client ID, client secret, and the raw settings + /// JSON (needed by providers with tenant-aware token URLs). async fn resolve_byoc_credentials( &self, account_id: &str, provider: &str, - ) -> Option<(String, String)> { + ) -> Option { let config = db::find_app_config(&self.pool, account_id, provider) .await .ok() @@ -584,7 +599,11 @@ impl PolicyEngine { .and_then(|v| v.as_str()) .map(String::from)?; - Some((client_id, client_secret)) + Some(ByocCredentials { + client_id, + client_secret, + settings: config.settings, + }) } } diff --git a/apps/web/public/icons/microsoft-graph.svg b/apps/web/public/icons/microsoft-graph.svg new file mode 100644 index 0000000..09a7e94 --- /dev/null +++ b/apps/web/public/icons/microsoft-graph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/src/lib/apps/microsoft-graph.ts b/apps/web/src/lib/apps/microsoft-graph.ts new file mode 100644 index 0000000..a648306 --- /dev/null +++ b/apps/web/src/lib/apps/microsoft-graph.ts @@ -0,0 +1,245 @@ +import { randomBytes, createHash } from "node:crypto"; +import type { EnvMapping } from "@/lib/env-mapping"; +import type { + AppDefinition, + OAuthBuildAuthUrlParams, + OAuthConfigField, + OAuthExchangeCodeParams, + OAuthExchangeResult, +} from "./types"; + +const DEFAULT_TENANT = "common"; + +/** + * In-memory store for PKCE code verifiers keyed by OAuth state. + * Entries are short-lived (consumed on callback) so a simple Map is fine + * for single-process deployments. For multi-replica setups the authorize + * and callback requests land on the same browser ↔ server pair thanks to + * the redirect, so this is safe. + */ +const pkceStore = new Map(); + +const tenantFromConfig = (config: Record): string => + config.tenantId || DEFAULT_TENANT; + +const authorizationEndpoint = (tenant: string) => + `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`; + +const tokenEndpoint = (tenant: string) => + `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`; + +/** + * Build a Microsoft identity platform authorization URL with PKCE. + */ +const buildMicrosoftGraphAuthUrl = ({ + clientId, + redirectUri, + scopes, + state, + config, +}: OAuthBuildAuthUrlParams): string => { + const tenant = tenantFromConfig(config); + + // PKCE: generate code verifier + challenge + const codeVerifier = randomBytes(32).toString("base64url"); + const codeChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + + // Stash verifier so exchangeCode can retrieve it + pkceStore.set(state, codeVerifier); + + const url = new URL(authorizationEndpoint(tenant)); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +}; + +/** + * Exchange an authorization code for Microsoft OAuth tokens. + */ +const exchangeMicrosoftGraphCode = async ({ + code, + clientId, + clientSecret, + redirectUri, + config, +}: OAuthExchangeCodeParams): Promise => { + const tenant = tenantFromConfig(config); + + const body: Record = { + grant_type: "authorization_code", + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }; + + // PKCE verifier lookup: pop the most recent entry. For single-flight OAuth + // this is deterministic. + for (const [key, verifier] of pkceStore) { + body.code_verifier = verifier; + pkceStore.delete(key); + break; + } + + const tokenRes = await fetch(tokenEndpoint(tenant), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body), + }); + + if (!tokenRes.ok) { + throw new Error( + `Microsoft token exchange failed: ${tokenRes.status} ${tokenRes.statusText}`, + ); + } + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + token_type?: string; + error?: string; + error_description?: string; + }; + + if (tokenData.error || !tokenData.access_token) { + throw new Error( + tokenData.error_description ?? "Failed to exchange code for token", + ); + } + + const expiresAt = tokenData.expires_in + ? Math.floor(Date.now() / 1000) + tokenData.expires_in + : undefined; + + const credentials: Record = { + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + token_type: tokenData.token_type, + expires_at: expiresAt, + }; + + const scopes = tokenData.scope?.split(" ").filter(Boolean) ?? []; + + // Fetch user info for metadata (display name, email) + let metadata: Record | undefined; + const userRes = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + + if (userRes.ok) { + const user = (await userRes.json()) as { + id?: string; + mail?: string; + userPrincipalName?: string; + displayName?: string; + }; + metadata = { + username: user.mail ?? user.userPrincipalName, + name: user.displayName, + }; + } + + return { credentials, scopes, metadata }; +}; + +const microsoftGraphConfigFields: OAuthConfigField[] = [ + { + name: "clientId", + label: "Client ID", + description: "Application (client) ID from your Azure app registration.", + placeholder: "e.g. 12345678-abcd-1234-abcd-1234567890ab", + }, + { + name: "clientSecret", + label: "Client Secret", + description: "Client secret value from Certificates & secrets.", + placeholder: "Azure app client secret", + secret: true, + }, + { + name: "tenantId", + label: "Tenant ID", + description: + 'Azure AD tenant ID or "common" for any account, "organizations" for work/school only.', + placeholder: "common", + }, +]; + +const microsoftGraphEnvMappings: EnvMapping[] = [ + { envName: "MICROSOFT_GRAPH_TOKEN", placeholder: "humr:sentinel" }, +]; + +export const microsoftGraph: AppDefinition = { + id: "microsoft-graph", + name: "Microsoft Graph", + icon: "/icons/microsoft-graph.svg", + description: + "Read calendar events and Teams meeting transcripts (scheduled meetings only).", + connectionMethod: { + type: "oauth", + defaultScopes: [ + "Calendars.Read", + "OnlineMeetings.Read", + "OnlineMeetingTranscript.Read.All", + "User.Read", + "offline_access", + ], + permissions: [ + { + scope: "Calendars.Read", + name: "Read your calendar", + description: + "List calendar events to find scheduled Teams meetings with transcripts.", + access: "read", + }, + { + scope: "OnlineMeetings.Read", + name: "Read your online meetings", + description: "Resolve a meeting ID from its Teams join URL.", + access: "read", + }, + { + scope: "OnlineMeetingTranscript.Read.All", + name: "Read your meeting transcripts", + description: + "Read transcripts for meetings you can access in Teams. Despite the '.All' suffix, this is per-user — admin consent is required by Microsoft policy but does not grant cross-user access.", + access: "read", + }, + { + scope: "User.Read", + name: "Read your profile", + description: "Sign in and read your basic profile info.", + access: "read", + }, + { + scope: "offline_access", + name: "Offline access", + description: + "Refresh tokens so the app can keep working without re-authenticating.", + access: "read", + }, + ], + buildAuthUrl: buildMicrosoftGraphAuthUrl, + exchangeCode: exchangeMicrosoftGraphCode, + }, + available: true, + configurable: { + fields: microsoftGraphConfigFields, + envDefaults: { + clientId: "MICROSOFT_GRAPH_CLIENT_ID", + clientSecret: "MICROSOFT_GRAPH_CLIENT_SECRET", + tenantId: "MICROSOFT_GRAPH_TENANT_ID", + }, + }, + envMappings: microsoftGraphEnvMappings, +}; diff --git a/apps/web/src/lib/apps/registry.ts b/apps/web/src/lib/apps/registry.ts index 34fa2ff..11be00b 100644 --- a/apps/web/src/lib/apps/registry.ts +++ b/apps/web/src/lib/apps/registry.ts @@ -11,6 +11,7 @@ import { googleDrive } from "./google-drive"; import { googleForms } from "./google-forms"; import { googleHealth } from "./google-health"; import { googleMeet } from "./google-meet"; +import { microsoftGraph } from "./microsoft-graph"; import { googlePhotos } from "./google-photos"; import { googleSearchConsole } from "./google-search-console"; import { googleSheets } from "./google-sheets"; @@ -33,6 +34,7 @@ export const apps: AppDefinition[] = [ googleForms, googleHealth, googleMeet, + microsoftGraph, googlePhotos, googleSearchConsole, googleSheets,