Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion apps/gateway/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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] = &[
Expand Down Expand Up @@ -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 ─────────────────────────────────────
Expand Down Expand Up @@ -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>,
Expand All @@ -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()),
Expand Down
29 changes: 24 additions & 5 deletions apps/gateway/src/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value>,
}

// ── PolicyEngine ───────────────────────────────────────────────────

/// Resolves CONNECT policy by querying the database directly via SQLx
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ByocCredentials> {
let config = db::find_app_config(&self.pool, account_id, provider)
.await
.ok()
Expand All @@ -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,
})
}
}

Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/icons/microsoft-graph.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading