navree
Documentation

Integrations

Connect each workspace or organization to Microsoft 365, Google Workspace, Slack, any OAuth 2.0 API or an API key: admin consent, service accounts, encrypted credentials, token refresh, least-privilege scope sets, health checks and typed clients.

On this page

Introduction

Featureintegrations A business app rarely works alone. It reads a customer's directory from Microsoft 365, sends mail through their Google Workspace, posts to their Slack and pulls alerts from the security tools they already run. Each of your customers connects their own accounts, and each connection has to keep working for months.

navree-integrations gives every workspace or organization in your app its own connections to third-party APIs:

  • Providers: Microsoft Graph (admin consent with app-only tokens, or delegated tokens), Google Workspace (service-account keys with domain-wide delegation, or OAuth consent), Slack (app installs, with token rotation), any OAuth 2.0 API by its endpoints, and API keys sent in a header.
  • Connect flows: a consent URL with a sealed state (and PKCE where the provider supports it), and a callback that stores the connection. Service-account keys and API keys are posted and tried before they are saved.
  • Encrypted credentials: refresh tokens, keys and cached access tokens are encrypted with your application key in integration_connections and never leave the server.
  • Token refresh under a lock: a connection's token is refreshed once however many requests and instances need it at the same time, and rotated refresh tokens are kept.
  • Scope sets: each feature declares the scopes it needs, and each scope set is connected on its own, so reading the directory never grants sending mail.
  • Health checks: integrations:check runs on the scheduler, tries every connection's credentials and emits ConnectionUnhealthy when one stops working.
  • Typed clients: Connection<MicrosoftGraph> in a handler, then graph.get("/users").send().await?, on the app's HTTP client and its SSRF policy.

Signing people in with these providers is a different job: social sign-in links a provider account to one user. Integrations connect a tenant's account to a workspace or an organization, whoever is signed in.

Installation

Turn on the integrations feature of the navree facade. It is part of the default full feature.

Cargo.toml

Add IntegrationsModule to your app. It encrypts credentials with the application key, so include CryptoModule, and it calls the providers through HttpClientModule:

src/app.rs

Only ConfigModule, CryptoModule and DatabaseModule are required. Without CacheModule, token locks and used states live in the process, which is enough for a single instance; with several instances, give the cache a shared store such as Redis so two instances never refresh the same connection at once. Without WorkspaceModule, connections belong to the app itself.

The module contributes the migration set integrations, which creates integration_connections: one row per owner, provider and scope set, with the encrypted credentials, the cached token and its expiry, the granted scopes, the tenant at the provider, and the health of the connection. It runs on PostgreSQL (with or without TimescaleDB), MySQL, MariaDB and SQLite; its UUID columns are BINARY(16) on MySQL and MariaDB, where models write UUIDs as 16 bytes.

Configuration

Register an app with each provider you offer, then list the providers in the integrations section. Providers that connect through a consent screen send the admin back to {redirect_base}/{provider}; register that exact URL with the provider.

config/app.toml

Client secrets go in .env:

.env

The keys microsoft, google and slack name their type themselves; any other key needs a type. A provider without its client_id, an oauth2 provider without its three URLs, a URL that isn't absolute, a consent provider without redirect_base or a key with upper-case letters fails the boot, naming the key. IntegrationsModule::for_root(IntegrationsOptions { .. }) takes the same options in code.

Providers

TypeConnects withCalls
microsoftAdmin consent: a tenant admin approves your app once and Navree mints app-only tokens for that tenant (client credentials). With mode = "delegated", a signed-in admin's own tokens instead (authorization code, offline_access, PKCE).https://graph.microsoft.com/v1.0
googleA service-account key with domain-wide delegation; Navree signs the RS256 assertion with the key and acts as subject. With a client_id, also OAuth consent (offline access, PKCE).https://www.googleapis.com and the other *.googleapis.com hosts
slackAn app install (OAuth v2). Bot tokens are used as they are; with token rotation on, they are refreshed.https://slack.com/api
oauth2Authorization code with PKCE and refresh tokens, at the authorize_url and token_url you configure.api_url
api_keyA key an admin pastes, sent in header as {scheme} {key} (Authorization: Bearer ... by default).api_url, or the connection's own

microsoft.tenant picks who may consent (organizations by default; common or one tenant id). Extra authorization URL parameters go in params, and pkce = false turns the challenge off for an oauth2 provider that rejects it. Slack takes no PKCE challenge.

Connecting a provider

Backend for frontendThe browser calls only its own web server, on the same origin, and holds cookies it can't read. The Next.js BFF keeps the access token (navree_at) and the refresh token (navree_rt) in httpOnly cookies and calls the Navree API with Authorization: Bearer. When the access token expires it refreshes once per refresh token, and Navree rotates the refresh token on every use. The Navree API reads and writes PostgreSQL in one transaction per request, and after a change it broadcasts to its WebSocket server, which pushes the event straight to the browser. The socket carries no token; private channels are authorized through the BFF.Browsercookies it can't readNext.js BFFhttpOnly cookiesnavree_ataccessnavree_rtrefreshNavree APIHTTP and WebSocketPostgreSQLusers · token familiessame originBearerrefresh,rotatesone transaction
Backend for frontend
  • request
  • real-time event

An admin connects a provider from your settings page. Navree's routes are token-based and a browser app keeps its tokens in its backend-for-frontend (BFF), so the flow runs through the BFF:

  1. The settings page links to a BFF route, say /integrations/microsoft/connect. The BFF calls GET /api/integrations/microsoft/connect as the admin, stores the returned state in a short-lived httpOnly cookie and redirects the browser to url.
  2. The admin approves at the provider, and the provider sends the browser to https://app.acme.test/settings/integrations/callback/microsoft?....
  3. The callback page checks that the state in the URL equals the one in its cookie, then forwards the query string, unchanged, to GET /api/integrations/microsoft/callback as the same admin.
  4. Navree checks the state, finishes the flow with the provider and answers 201 with the connection and the return_to path.

The state is sealed with your application key. It names the provider, the scope set, the workspace or organization, and the admin who started the flow, so a state can't be finished for another workspace or by another user. It expires after 10 minutes (integrations.state_ttl_secs) and works once.

HTTP
JSON

Microsoft sends the tenant back instead of a code:

HTTP
201 Created

external_id is the connected tenant: the Microsoft tenant id, the Slack team id or the Google Workspace domain. For OAuth providers the callback carries a code, which Navree exchanges for tokens (with the PKCE verifier from the state). Connecting the same provider and scope set again replaces the credentials and keeps the connection's id.

Service accounts

Google Workspace connects without a consent screen: a Google Workspace admin creates a service account, authorizes its client id for your scopes under domain-wide delegation in the Admin console, and uploads the key file to your settings page. Your page posts it:

HTTP

key is the file's JSON object or its text. Navree tries the key first: it signs an RS256 assertion for the scope set's scopes, acting as subject (an admin, for directory reads), and asks Google for a token. A key Google refuses (unauthorized_client, when delegation isn't set up for those scopes) is a 400 and isn't stored. The key's token_uri is ignored; tokens always come from the configured token_url.

API keys

Security tools and other services hand out keys instead of consent screens. Post the key, and the connection's own API root when it differs per customer:

HTTP

With a health_url, the key is tried there first and a refused key (401, 403) is a 400. fields are stored encrypted with the key, for values such as a region or an account id. Code that signs its own requests reads them with integrations.api_key(id).

Scope sets

A connection with every scope your app might need is a liability: a token leaked from the directory sync could send mail as the CEO. Scope sets keep each feature to what it needs, and each one is connected on its own, so revoking one leaves the others working.

A feature declares its scope set as a type:

src/modules/directory/scopes.rs

Register it from the feature's module with IntegrationsModule::for_feature::<DirectoryRead>() in its imports, or with m.integration_scope_set::<DirectoryRead>() in its register. The settings page lists each provider's scope sets from GET /api/integrations/providers, and connects one with ?scope_set=directory. A provider for which the set names no scopes doesn't offer it (400 unknown_scope_set). The default scope set is the provider's configured scopes.

Microsoft app-only tokens carry every permission the app registration holds, whatever the consent screen asked for. To keep mail send apart from directory read, give the scope set its own registration:

TOML

The mail_send consent screen and its tokens then use that registration, which holds Mail.Send and nothing else.

Workspaces and organizations

integrations.level says whose connections the routes and the extractor use:

LevelOwnerResolved by
workspace (default)The request's workspaceThe InWorkspace guard: X-Workspace, a path, a subdomain or a claim. The admin needs integrations.manage in that workspace.
organizationThe request's organizationThe InOrganization guard (X-Organization, ...). Needs workspace.organizations.enabled.
appThe app itselfNothing. Without WorkspaceModule, workspace means app.

A workspace without its own connection uses its organization's, so a company connects Microsoft 365 once at the organization and every workspace in it can read the directory. The workspace's own connection comes first when it has one. GET /api/integrations/connections in a workspace lists its own connections, then its organization's (owner_type: "organization"); a workspace admin can read and check an inherited connection but not remove it (404). Deleting a workspace or an organization deletes its connections.

Calling the APIs

Connection<P> gives a handler the request owner's connection to provider P and its typed client. Name a scope set as the second parameter:

src/modules/directory/controller.rs

Paths resolve against the provider's API root. Every request carries a current token: the cached one while it is fresh, else a new one from the provider, minted or refreshed under a lock so concurrent requests refresh a connection once. A 401 gets one more try with a new token. A missing connection is a 404 integration_not_connected, so the page can offer "Connect Microsoft 365".

ClientProviderAdds
GraphClientMicrosoftGraphpaginate::<T>(path) follows @odata.nextLink; tenant_id()
GoogleClientGoogleWorkspacepaginate::<T>(path, items_key) follows nextPageToken; as_user(email) acts as another user through domain-wide delegation; domain()
SlackClientSlackcall(method, &args) posts JSON and turns ok: false into IntegrationsError::Api (ratelimited into Unavailable); team_id()
IntegrationClientanyget, post, put, patch, delete, request(method, path), then .query(..), .json(..), .form(..), .header(..), .timeout(..) and .send() or .send_json::<T>(); token(), connection(), base_url()

Each typed client dereferences to IntegrationClient, so graph.get("/organization") works too. send() returns the response as it came; send_json() turns a 4xx or 5xx into an error. An absolute URL works only on the provider's own hosts (*.googleapis.com for Google), so a token is never sent anywhere else.

Outside a request, in a job or a command, ask the Integrations service for the client with the owner:

src/modules/directory/sync.rs

client_for::<P>(owner) takes the default scope set, client_in::<P, S>(owner) scope set S, and client(owner, provider, scope_set) a provider by key. Give an oauth2 or api_key provider a type of its own to use it in Connection<P>:

Rust

The Integrations service

The routes, the extractor and the commands are built on the Integrations service:

MethodWhat it does
providers()The configured providers, how each connects and its scope sets (ProviderInfo)
connect_url(owner, provider, ConnectOptions { scope_set, return_to, user_id, params })The consent URL and sealed state (ConnectRedirect)
complete(owner, provider, &callback, user_id)Checks the state and stores the connection (CompletedConnection)
connect_service_account(owner, provider, NewServiceAccount::new(key, subject))Tries and stores a service-account key
connect_api_key(owner, provider, NewApiKey::new(key))Tries and stores an API key
connections(owner), connection(owner, provider, scope_set)The connections an owner can use (its own, then its organization's)
find(id), find_for(owner, id), all()One connection; one the owner can use; every connection
disconnect(id), disconnect_for(owner, id)Removes a connection, revoking OAuth tokens where the provider supports it (Google, Slack, revoke_url)
token(id), api_key(id)A current access token; an API key with its fields
client(..), client_for::<P>(..), client_in::<P, S>(..), client_of(&connection)Typed clients
check(id), check_due(), check_all()Health checks

Connecting and disconnecting are data changes: in a handler they join the request's unit of work, are audited and emit their events after the commit. Token refreshes and health results commit on their own, so a request that fails afterwards keeps a rotated refresh token.

The connections table

IntegrationConnection is the model of integration_connections, so reports and admin pages query connections like any other records:

src/modules/integrations/report.rs

Its credentials and access_token columns hold ciphertexts; they are hidden from serialized records and from Debug. Read the table freely, and change connections through Integrations, which encrypts the credentials, emits the events and writes the audit entries. ConnectionInfo is the view to return from your own routes.

Notification bot routes

With notifications in the app too (the notify feature; integrations and notify are both in full), IntegrationsModule registers notifications' dyn ConnectionTokens (IntegrationTokens). A Slack or Teams route that names a connection then sends with that connection's token, from token(id):

Rust

Watched mailboxes

With inbound mail in the app too (the mail-inbound feature; both are in full), IntegrationsModule provides MailboxWatches. It watches the Microsoft 365 and Gmail mailboxes of a connection, through a Graph subscription or Gmail's users.watch and a Pub/Sub push, and hands every new message to the inbound pipeline, where your mailboxes handle it like mail from any other source:

Rust

The watches are integration_mailbox_watches rows in the integrations migration set, renewed hourly by integrations:mailboxes --renew. The inbound mail page covers the configuration (integrations.mailboxes), the routes and the scopes a connection needs.

Health checks

A connection stops working without telling you: an admin removes your app from their tenant, a key is rotated, a service account is deleted. integrations:check finds out before your users do. For each connection it asks the provider for a new token (OAuth, app-only and service-account connections) and gets the provider's health probe (health_url, or Slack's auth.test). Then:

  • It works: the connection is healthy, its failure count goes back to 0.
  • The provider refuses the credentials (invalid_grant, unauthorized_client, a 401 or 403 from the probe): the connection is unhealthy at once.
  • The provider can't be reached (a 5xx, a timeout): the failure is counted, and the connection turns unhealthy after integrations.unhealthy_after failures in a row (3).

The module schedules integrations:check --due every five minutes; it checks each connection whose last check is older than integrations.check_interval_mins (60). Add ScheduleModule and run schedule:work (or schedule:run from cron) to start it, and set the interval to 0 to turn scheduled checks off. A token request in normal use that the provider refuses also turns the connection unhealthy, and answers 409 integration_reauthorization_required.

Shell
Text

Listen for the event to tell the workspace's admins:

src/modules/integrations/listeners.rs

Register it with m.listen::<ConnectionUnhealthy, WarnAdmins>(), and send a notification from it.

Events and audit entries

EventNAMEWhen
IntegrationConnectedintegrations.connectedA connection was made, or made again
IntegrationDisconnectedintegrations.disconnectedA connection was removed
ConnectionUnhealthyintegrations.unhealthyA healthy connection stopped working (once, on the change)
ConnectionRestoredintegrations.restoredAn unhealthy connection works again

Each carries connection_id, owner ({"type": "workspace", "id": "..."}), provider and scope_set; IntegrationConnected adds kind and connected_by, and ConnectionUnhealthy adds error and failures. They are emitted after the change commits, when EventsModule is in the app.

With the audit log, connecting and disconnecting are recorded as integration.connected and integration.disconnected, by the admin who did it, with the connection as the subject (IntegrationConnection and its id) and the provider, scope set, kind and owner as metadata:

JSON

The rows are written without row-level change events, so neither the credentials nor their ciphertexts reach the audit log. Token refreshes and health checks aren't recorded.

Errors

AnswercodeWhen
400 Invalid or expired stateinvalid_stateThe state was changed, issued for another provider, owner or admin, has expired, or was used already
400 Microsoft 365 consent was not granted: access_deniedconsent_deniedThe admin declined, or Microsoft didn't report the consent
400 Slack refused the connection: invalid_codeintegration_refusedThe provider refused the code, the key or the service account
400invalid_credentialsA key file that isn't a service-account key, an empty API key, a subject that isn't an email address
400 Unknown scope set ...unknown_scope_setThe scope set isn't registered, or names no scopes for this provider
400 Slack does not connect with an API keyunsupported_connectionThe provider doesn't connect this way
400invalid_return_toreturn_to isn't a relative path
404unknown_integrationNo provider is configured under this key
404 Microsoft 365 is not connectedintegration_not_connectedThe owner has no connection for this provider and scope set
404connection_not_foundNo connection has this id, or it belongs to someone else
409 Connect Microsoft 365 again to continueintegration_reauthorization_requiredThe provider refused the connection's credentials
502 Slack is unavailable. Please try again.integration_unavailableThe provider couldn't be reached
502integration_api_errorA Slack call answered ok: false

Every answer carries its code in the error body. IntegrationsError returned from a handler with ? keeps its answer.

Testing

Integrations::fake() returns an Integrations whose providers are an IntegrationsFake. States are still sealed and checked, credentials still encrypted and stored and service-account keys still sign their assertions; only the provider's side is replaced, and the typed clients send through a fake HTTP client you stub:

tests/integrations.rs
IntegrationsFakeDoes
on_code(provider, code, FakeGrant)Answers a consent code with a grant
on_code_error(provider, code, error)Refuses a code (invalid_grant): a 400 integration_refused
on_token(provider, FakeGrant)Answers refreshes, app-only and service-account tokens with a grant
refuse_tokens(provider, error)Refuses them: the connection turns unhealthy
fail_tokens(provider)Makes the token endpoint unreachable
restore_tokens(provider)Back to generated tokens
api()The FakeServer behind the typed clients and health probes
minted(), exchanged(), revoked()What was asked: grants (Minted { provider, grant_type, scope_set, subject, client_id }), codes and revoked tokens

FakeGrant::new() generates an access token that lasts an hour; set access_token, refresh_token, expires_in, never_expires, scopes, external_id and display_name as you need. A code the fake doesn't know is refused with invalid_grant, as a provider would. To test against a provider's real responses, keep the real Integrations, point its endpoint keys (authority_url, token_url, api_url, revoke_url) at a local mock server, and let the HTTP client reach it (http_client.ssrf.allow_hosts = ["127.0.0.1"]).

Reference

KeyDefaultMeaning
integrations.redirect_basenoneCallback URLs are {redirect_base}/{provider}; required for consent providers
integrations.routestrueMounts the /api/integrations routes
integrations.levelworkspaceworkspace, organization or app
integrations.state_ttl_secs600Lifetime of a sealed state
integrations.check_interval_mins60Scheduled checks: how old a check may get; 0 turns them off
integrations.unhealthy_after3Transient failures in a row before a connection is unhealthy
integrations.lock_timeout_secs15How long a refresh waits for another refresh of the same connection
integrations.providers.<key>.typethe keymicrosoft, google, slack, oauth2 or api_key
integrations.providers.<key>.namethe provider's, or the keyShown in messages and lists
integrations.providers.<key>.client_id, .client_secretnoneThe OAuth app; the secret from .env
integrations.providers.<key>.scopes[]The default scope set (a list, or a comma-separated string)
integrations.providers.<key>.params{}Extra authorization URL parameters
integrations.providers.<key>.pkcetrue (Slack: false)Sends a PKCE challenge (S256)
integrations.providers.<key>.client_authpostpost or basic at the token endpoint
integrations.providers.<key>.tenantorganizationsMicrosoft: who may consent
integrations.providers.<key>.modeappMicrosoft: app (admin consent, app-only tokens) or delegated
integrations.providers.<key>.apps.<scope set>.client_id, .client_secretnoneA separate app registration for one scope set
integrations.providers.<key>.authorize_url, .token_url, .api_url, .revoke_urlthe provider'sEndpoints; required for oauth2 (but revoke_url)
integrations.providers.<key>.authority_urlhttps://login.microsoftonline.comMicrosoft's sign-in host
integrations.providers.<key>.health_urlnoneA path under the API root that checks get; any 2xx is healthy
integrations.providers.<key>.header, .schemeAuthorization, Bearerapi_key: where the key goes
integrations.mailboxes.graph_notification_url, .gmail_topic, .gmail_token, .renew_before_hoursnone, none, none, 24Watched mailboxes, with mail-inbound
RouteAnswer
GET /api/integrations/providers{data: [ProviderInfo]}
GET /api/integrations/connections{data: [ConnectionInfo]}
GET /api/integrations/connections/{id}The connection
POST /api/integrations/connections/{id}/checkThe Health after a check
DELETE /api/integrations/connections/{id}204
GET /api/integrations/{provider}/connect?scope_set&return_to{url, state}
GET /api/integrations/{provider}/callback?state&code&tenant&admin_consent&error201, the connection and return_to
POST /api/integrations/{provider}/service-account201, the connection
POST /api/integrations/{provider}/api-key201, the connection

Every route requires the integrations.manage permission, in the request's workspace or organization at those levels.

CommandDoes
integrations:checkChecks every connection; --due the ones due (what the scheduler runs); --id <uuid> one
integrations:listLists the connections with their owner, kind and health
integrations:mailboxesLists watched mailboxes; --renew renews the due ones (hourly on the scheduler); --sync <id> catches one up (with mail-inbound)