Skip to content
Draft
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
17 changes: 17 additions & 0 deletions infra/stacks/email-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ const GMAIL_GCP_QUEUE = aws.secretsmanager
.getSecretVersionOutput({ secretId: gmailGcpQueue })
.apply((secret) => secret.secretString);

// Optional Outlook (Microsoft Graph) webhook configuration. Empty until the
// Outlook integration is provisioned; the email service treats empty values as
// "Outlook disabled" and still boots. When provisioning, point
// OUTLOOK_NOTIFICATION_URL at the public /outlook/webhook endpoint and source
// OUTLOOK_CLIENT_STATE from Secrets Manager (the Graph subscription clientState
// secret).
const OUTLOOK_NOTIFICATION_URL = config.get(`outlook_notification_url`) ?? '';
const OUTLOOK_CLIENT_STATE = config.get(`outlook_client_state`) ?? '';

const jwtSecretKeyArn: pulumi.Output<string> = aws.secretsmanager
.getSecretVersionOutput({ secretId: JWT_SECRET_KEY })
.apply((secret) => secret.arn);
Expand Down Expand Up @@ -438,6 +447,14 @@ const containerEnvVars = [
name: 'GMAIL_GCP_QUEUE',
value: pulumi.interpolate`${GMAIL_GCP_QUEUE}`,
},
{
name: 'OUTLOOK_NOTIFICATION_URL',
value: OUTLOOK_NOTIFICATION_URL,
},
{
name: 'OUTLOOK_CLIENT_STATE',
value: OUTLOOK_CLIENT_STATE,
},
{
name: 'NOTIFICATION_QUEUE',
value: pulumi.interpolate`${notificationIngressQueueName}`,
Expand Down
28 changes: 25 additions & 3 deletions js/app/packages/app/component/settings/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import PaywallTeamMemberView from '../paywall/PaywallTeamMemberView';
import PaywallTeamOwnerView from '../paywall/PaywallTeamOwnerView';
import { ROUTER_BASE_CONCAT } from '@app/constants/routerBase';
import { useEmailLinks, useEmailLinksStatus } from '@core/email-link';
import { useInitGmailLink } from '@queries/auth';
import { useInitGmailLink, useInitOutlookLink } from '@queries/auth';
import { useRemoveInboxMutation } from '@queries/email/link';
import {
type SupportedNotificationSettings,
Expand Down Expand Up @@ -358,6 +358,17 @@ export function Account() {
}
};

const initOutlookLink = useInitOutlookLink();
const handleAddOutlookInbox = async () => {
const callbackUrl = `${window.location.origin}${ROUTER_BASE_CONCAT}inbox-link-callback`;
const result = await initOutlookLink.mutateAsync(callbackUrl);
if (result.isOk()) {
window.location.href = result.value.authorization_url;
} else {
toast.failure('Failed to start Outlook link flow');
}
};

const handleResyncInbox = async (linkId: string) => {
setResyncingIds((prev) => new Set(prev).add(linkId));
await resyncInbox(linkId).match(
Expand Down Expand Up @@ -653,17 +664,28 @@ export function Account() {
</Button>
}
>
<Tooltip label="Add inbox">
<Tooltip label="Add Gmail inbox">
<Button
variant="base"
size="sm"
depth={3}
onClick={handleAddInbox}
aria-label="Add inbox"
aria-label="Add Gmail inbox"
>
<PlusIcon class="size-4" />
</Button>
</Tooltip>
<Tooltip label="Add Outlook inbox">
<Button
variant="base"
size="sm"
depth={3}
onClick={handleAddOutlookInbox}
aria-label="Add Outlook inbox"
>
Outlook
</Button>
</Tooltip>
</Show>
</Show>
</div>
Expand Down
1 change: 1 addition & 0 deletions js/app/packages/queries/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { useInitGmailLink } from './gmail-link';
export { authKeys } from './keys';
export { useSendMobileWelcomeEmail } from './mobile-welcome-email';
export {} from './mutations';
export { useInitOutlookLink } from './outlook-link';
export type { UserInfoData } from './user-info';
export {
normalizeUserNameQueryId,
Expand Down
16 changes: 16 additions & 0 deletions js/app/packages/queries/auth/outlook-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { authServiceClient } from '@service-auth/client';
import { useMutation } from '@tanstack/solid-query';

/**
* Mutation that asks auth-service for the Microsoft OAuth authorization URL for
* adding an Outlook inbox to the already-authenticated user. Callers consume the
* `authorization_url` and navigate the browser to it. Mirrors
* {@link useInitGmailLink}.
*/
export function useInitOutlookLink() {
return useMutation(() => ({
mutationFn: async (originalUrl: string) => {
return authServiceClient.initOutlookLink(originalUrl);
},
}));
}
21 changes: 21 additions & 0 deletions js/app/packages/service-clients/service-auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,27 @@ export const authServiceClient = {
).map((result) => result);
},

/**
* Initializes an Outlook (Microsoft) account link for the already-authenticated
* user (multi-inbox flow). Mirrors {@link initGmailLink}: returns the OAuth
* authorization URL to redirect the browser to. After Microsoft consent, the
* user is redirected back to `originalUrl` with `?link_id=<uuid>` appended; the
* frontend then calls `emailClient.init({ linkId })` to provision the inbox.
*
* The response shape matches `InitGmailLinkResponse`; it's inlined here so the
* client doesn't depend on a regenerated `InitOutlookLinkResponse` schema.
*/
async initOutlookLink(originalUrl?: string) {
const url = originalUrl
? `${authHost}/link/outlook?original_url=${encodeURIComponent(originalUrl)}`
: `${authHost}/link/outlook`;
return (
await fetchWithAuth<{ authorization_url: string; link_id: string }>(url, {
method: 'POST',
})
).map((result) => result);
},

/**
* Deletes a github link for a user
* NOTE: this does not delete the github application from being installed on a teams repository
Expand Down
19 changes: 19 additions & 0 deletions rust/cloud-storage/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/cloud-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ members = [
"memory",
"notification_sandbox",
"notification_service",
"outlook_client",
"organization_retention_handler",
"organization_retention_trigger",
"properties_service",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
use axum::{
Json,
extract::{self, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use fusionauth::FusionAuthClient;
use fusionauth::error::FusionAuthClientError;
use macro_middleware::auth::internal_access::ValidInternalKey;
use model::authentication::microsoft_token::MicrosoftAccessToken;
use model::response::ErrorResponse;
use std::sync::Arc;

/// FusionAuth identity-provider name for the Microsoft (Outlook) IdP. Mirrors
/// the `google_gmail` name used for Gmail.
pub(crate) const OUTLOOK_IDENTITY_PROVIDER_NAME: &str = "microsoft_outlook";

#[derive(serde::Deserialize, Debug)]
pub struct MicrosoftAccessTokenParams {
fusionauth_user_id: String,
/// The linked Microsoft account's email — what FusionAuth stores as
/// `display_name` on the IdP link. Discriminates one Microsoft account from
/// another when the FA user has multiple Microsoft IdP links.
email: String,
}
Comment on lines +18 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop tracing raw query params with user identifiers/email.

Current span instrumentation captures params (and Debug-formats it), which can leak PII in traces/log pipelines.

Suggested fix
-#[derive(serde::Deserialize, Debug)]
+#[derive(serde::Deserialize)]
 pub struct MicrosoftAccessTokenParams {
@@
-#[tracing::instrument(skip(auth_client, _internal_access))]
+#[tracing::instrument(skip(auth_client, _internal_access, params))]
 pub async fn handler(
@@
-#[tracing::instrument(skip(auth_client))]
+#[tracing::instrument(skip(auth_client, params))]
 async fn get_access_token(

Also applies to: 29-45

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@rust/cloud-storage/authentication_service/src/api/internal/microsoft_access_token.rs`
around lines 18 - 25, The span is currently capturing and Debug-formatting
MicrosoftAccessTokenParams (which contains PII) exposing user identifiers/email;
remove the Debug derive from MicrosoftAccessTokenParams and change the span
instrumentation (the code that records `params` around lines 29-45) to avoid
logging the whole struct — either log only non-PII indicators (e.g., a boolean
like `has_email_link` or `fusionauth_user_id_present`), log a redacted field
(e.g., email => "<redacted>"), or implement a custom Debug/formatter that
redacts sensitive fields before passing to the span. Keep serde::Deserialize on
MicrosoftAccessTokenParams so deserialization still works but ensure any
trace/log call uses the redacted or minimal data instead of the raw params.


/// Gets a Microsoft (Outlook) access token for the linked account. Mirrors the
/// Gmail `google_access_token` handler.
#[tracing::instrument(skip(auth_client, _internal_access))]
pub async fn handler(
State(auth_client): State<Arc<FusionAuthClient>>,
_internal_access: ValidInternalKey,
extract::Query(params): extract::Query<MicrosoftAccessTokenParams>,
) -> Result<Response, Response> {
get_access_token(auth_client, &params, OUTLOOK_IDENTITY_PROVIDER_NAME).await
}

/// Fetches an access token for a user from the Microsoft identity provider by
/// looking up their IdP link and refreshing the stored refresh token.
#[tracing::instrument(skip(auth_client))]
async fn get_access_token(
auth_client: Arc<FusionAuthClient>,
params: &MicrosoftAccessTokenParams,
identity_provider_name: &str,
) -> Result<Response, Response> {
let fusionauth_user_id = params.fusionauth_user_id.as_str();
let email = params.email.as_str();

// get identity provider id
let idp_id = auth_client
.get_identity_provider_id_by_name(identity_provider_name)
.await
.map_err(|e| {
tracing::error!(error=?e, "unable to find idp id for {}", identity_provider_name);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
message: "unable to find idp".into(),
}),
)
.into_response()
})?;

// get refresh token via link
let links = auth_client
.get_links(fusionauth_user_id, Some(idp_id.clone()))
.await
.map_err(|e| {
tracing::error!(error=?e, "error fetching links for userid {} and idp id {}", fusionauth_user_id, idp_id.as_str());
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
message: "unable to fetch links".into(),
}),
)
.into_response()
})?;

// a fusionauth user can have multiple links to the same identity provider with different email
// addresses, but can only have one link with a given email
let link = links
.into_iter()
.find(|l| l.display_name.as_str() == email)
.ok_or_else(|| {
tracing::error!(
"link not found for user id {} and idp id {}",
fusionauth_user_id,
idp_id.as_str()
);
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
message: format!("No {} link found for this user", identity_provider_name)
.into(),
}),
)
.into_response()
})?;

// get access token using refresh token
let token_response = auth_client
.refresh_microsoft_token(link.token.as_str())
.await
.map_err(|e| {
tracing::error!(error=?e, "error fetching microsoft access token for userid {}", fusionauth_user_id);
let status_code = match &e {
FusionAuthClientError::InvalidGrant => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = format!("unable to fetch {} access token", identity_provider_name);
(status_code, Json(ErrorResponse { message: message.into() })).into_response()
})?;

Ok((
StatusCode::OK,
Json(MicrosoftAccessToken {
access_token: token_response.access_token,
}),
)
.into_response())
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ use super::user::post_get_names;

// needs to be public in api crate for swagger
mod google_access_token;
mod microsoft_access_token;
mod post_get_existing_users;
mod remove_link;

pub fn router() -> Router<ApiContext> {
Router::new()
.route("/google_access_token", get(google_access_token::handler))
.route(
"/microsoft_access_token",
get(microsoft_access_token::handler),
)
.route("/get_names", post(post_get_names::handler_internal))
.route("/get_existing_users", get(post_get_existing_users::handler))
.route("/remove_link", delete(remove_link::handler))
Expand Down
6 changes: 6 additions & 0 deletions rust/cloud-storage/authentication_service/src/api/link/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use axum::{
pub(in crate::api) mod create_in_progress_link;
pub(in crate::api) mod github;
pub(in crate::api) mod gmail;
pub(in crate::api) mod outlook;

/// The link router
/// We ensure the user is logged in with the `macro_middleware::auth::decode_jwt::handler`.
Expand All @@ -20,4 +21,9 @@ pub fn router(_state: ApiContext) -> Router<ApiContext> {
)
.route("/gmail", post(gmail::init_gmail_link_handler))
.route("/gmail/status", get(gmail::check_gmail_link_status_handler))
.route("/outlook", post(outlook::init_outlook_link_handler))
.route(
"/outlook/status",
get(outlook::check_outlook_link_status_handler),
)
}
Loading
Loading