Sign-in Providers

Sign-in Providers

Some services have no key to paste. You do not get a QuickBooks API key the way you get a Stripe one — you sign in, consent to a scope, and the service hands back a token. TAP runs that flow for you: the tokens are minted server-side, stored encrypted, pinned to the service’s own API hosts, and refreshed or extended before a call that needs it. No token ever reaches the agent.

This page covers the providers configured by table. Google and Microsoft are sign-in providers too, with their own implementation and the same agent-facing shape — see Credential Setup.

Microsoft OneDrive. The Microsoft connector offers two OneDrive bundles next to the Outlook ones: files-read (https://graph.microsoft.com/Files.Read) and files-readwrite (https://graph.microsoft.com/Files.ReadWrite). They are not in the default set, so ask for them by id; a caller that names no scopes still gets Outlook only. The credential stays pinned to graph.microsoft.com, which covers /v1.0/me/drive/.... Reading a file’s bytes through /me/drive/items/{id}/content answers 302 to a short-lived, pre-authenticated download URL on another host; TAP does not follow it and does not send the token there. Fetch that URL directly, or read @microsoft.graph.downloadUrl from the item’s metadata.

Microsoft SharePoint. sharepoint-readwrite requests https://graph.microsoft.com/Sites.ReadWrite.All and https://graph.microsoft.com/Files.ReadWrite.All: the company’s sites, lists and document libraries, and files shared with the person, at /v1.0/sites/... and /v1.0/drives/... on the same pinned host. These are delegated permissions, so a connection never reaches more than the signing-in person can open. Microsoft lets a user consent to both, but a tenant whose policy requires admin approval for outside apps shows “Need admin approval” until an admin approves the app once. Opt-in, like OneDrive. It is read and write in one bundle because the Microsoft app registration lists only the ReadWrite forms; a read-only bundle would be dropped at consent.

Microsoft drafts, shared mailboxes and shared calendars. Three more opt-in bundles, all delegated and user-consentable:

BundlePermissionsFor
mail-readwriteMail.ReadWriteDrafts (so a reply can wait for approval), moving, flagging, categorizing. Sending stays mail-send
mail-sharedMail.Read.Shared, Mail.Send.SharedShared mailboxes the person can open (/v1.0/users/{mailbox}/messages)
calendar-sharedCalendars.ReadWrite.SharedCalendars others have shared with the person

Each is listed on the Microsoft app registration; a bundle whose permission is missing there would be dropped at consent and refused as a partial grant.

What a provider is

A provider is a row of configuration, not code:

FieldWhat it is
idURL segment and stored-bundle discriminator (quickbooks)
labelWhat a person reads on a connect card
authorize_url / token_urlThe provider’s two OAuth endpoints
scopesSelectable permission bundles, (id, label, scopes) — callers send bundle ids, never raw scope strings
pkceSend an RFC 7636 S256 challenge on authorize and the verifier on exchange
client_authbasic (HTTP Basic) or post (form fields) at the token endpoint
extra_authorize_paramsQuery parameters every consent URL for this provider carries
callback_params_to_keepCallback parameters that are part of the connection, not the handshake — QuickBooks’ realmId
allowed_hostsHost pin for the created credential; never empty
token_headerHeader the access token is injected into (default Authorization: Bearer)
refresh_rotatesThe provider issues a new refresh token on every redemption and kills the old one
refresh_sends_scopeSend the stored scopes as scope on a refresh. On for every row except HubSpot, whose refresh body has no scope parameter
send_scope / scope_separatorWhether the consent URL carries scope, and how scopes are joined (Meta wants commas)
authorize_param_varsConsent URL parameters read from the deployment — Facebook’s config_id
lifecycleRefreshToken (RFC 6749), or LongLived for providers that issue no refresh token (Meta): an optional exchange for a long-lived token at consent, and an optional in-place extension before expiry
resource_token_lookupPer-resource tokens fetched at consent and injected when a request’s path names that resource — Facebook Page tokens
redact_response_keysJSON keys redacted from every response to the credential — Meta’s access_token
token_prefix_overridesHosts that take a different token prefix — Meta’s upload host takes OAuth

The table lives in crates/tap-proxy/src/oauth_providers.rs. Adding a provider is a row plus two environment variables on the deployment — no new handler, no new callback, no new routing branch.

Endpoints

Each configured provider gets the same four routes, from one set of handlers:

RouteWho calls it
POST /team/oauth/{provider}/startThe dashboard — session-authenticated, owners and admins
POST /team/oauth/{provider}/reauthorizeThe dashboard, to repair an existing credential
POST /app/users/{ext_id}/credentials/oauth/{provider}/startA partner’s app key, for a managed end-user
POST /agent/credentials/oauth/{provider}/startA host app’s connect card, with the signed-in person’s token
GET /oauth/{provider}/callbackThe provider, returning the browser

GET /agent/connectors lists every provider this deployment offers with kind: "oauth", its scope bundles, and available — false when the client id or secret is missing here, with unavailable_reason naming the variable (never a value). A connect card reads that listing and needs no per-provider code of its own.

The agent start endpoint refuses an X-TAP-Key outright: consent is a human act, so it takes the signed-in person’s own token and applies their workspace role. Full request and response shapes: API Reference.

GET /team/oauth/providers is the same listing for the dashboard, which holds a session rather than an agent key. It returns every row as {id, label, available, unavailable_reason?, scopes, default_scopes, api_base, allowed_hosts}; unavailable_reason names the missing variable, never a value. Any workspace member may read it. Starting or reauthorizing a flow is still owners and admins only.

In the dashboard

Add credential lists every provider on the table under Sign-in providers, built from GET /team/oauth/providers, so a new row shows up with no dashboard change. A provider this deployment has not configured appears as a disabled tile naming the missing variable. Picking one asks for a credential name, a description and the scope bundles (defaults pre-checked), then sends the person to the provider’s consent screen.

Credentials a provider created are recognised by their exact api_base and badged with the provider’s label. Each such row has a Reconnect button, which calls POST /team/oauth/{provider}/reauthorize and keeps the scopes the credential already has. When consent returns, the callback’s dashboard redirect carries provider=<id> next to oauth= (and reason= on failure), so the result banner names the provider.

Using the credential

The credential lands as an ordinary TAP credential: reference it by name in X-TAP-Credential and call /forward as usual. TAP injects the provider’s token header — the agent never sees a token and cannot ask for one.

Two things worth knowing:

  • The host pin is real. The credential is bound to the provider’s own API hosts at creation. A /forward aimed anywhere else is refused before any injection, so a prompt-injected agent has nowhere to send the token.
  • Connection parameters are not secrets. Anything the provider’s callback returns that the API needs in the URL — QuickBooks’ company id — is stored with the credential and surfaced in that credential’s /agent/services entry under its own field (realm_id), alongside oauth_provider. The agent puts it in the URL itself.

Access tokens are cached in the stored bundle and refreshed only when expired. On a provider that rotates its refresh token, the new one is written back atomically, so two proxy instances refreshing at once cannot leave the credential holding a token the provider has already invalidated.

When a refresh finally fails with invalid_grant, /forward answers 502 with error_code: "oauth_reauth_required" and a reauth_url — the person reconnects and the same credential name keeps working.

QuickBooks Online

Provider idquickbooks
Authorizehttps://appcenter.intuit.com/connect/oauth2
Tokenhttps://oauth.platform.intuit.com/oauth2/v1/tokens/bearer
Client authHTTP Basic only. Intuit rejects the secret in the form body, and answers invalid_client when client_id is repeated in the body alongside the header
Scope bundlesaccounting (com.intuit.quickbooks.accounting, the default), payments (com.intuit.quickbooks.payment)
Kept callback parameterrealmId → realm_id
Allowed hostsquickbooks.api.intuit.com, sandbox-quickbooks.api.intuit.com
Refresh tokenRotates on every redemption; expires after 100 days idle
Access tokenOne hour

Deployment setup

Create an app in the Intuit developer portal, and register TAP’s callback as a redirect URI exactly as the proxy will send it:

https://<your TAP proxy host>/oauth/quickbooks/callback

Then set:

QUICKBOOKS_OAUTH_CLIENT_ID=...
QUICKBOOKS_OAUTH_CLIENT_SECRET=...
# Optional — only if the callback URL differs from {TAP_PROXY_URL}/oauth/quickbooks/callback
QUICKBOOKS_OAUTH_REDIRECT_URI=https://proxy.example/oauth/quickbooks/callback

Until both the id and the secret are set, /agent/connectors reports QuickBooks as available: false and every start endpoint answers 503 — a connect card renders no dead button.

Intuit issues sandbox keys immediately and production keys after review; the sandbox host (sandbox-quickbooks.api.intuit.com) is in the host pin so the same credential works against either.

Calling the API

QuickBooks addresses a company, so every request path carries the realm id. Get it from the credential’s /agent/services entry — never invent one:

# realm_id comes from GET /agent/services -> services["quickbooks"].realm_id
curl -X POST "$TAP_PROXY_URL/forward" \
  -H "X-TAP-Key: $TAP_AGENT_KEY" \
  -H "X-TAP-Credential: quickbooks" \
  -H "X-TAP-Target: https://quickbooks.api.intuit.com/v3/company/$REALM_ID/companyinfo/$REALM_ID" \
  -H "X-TAP-Method: GET" \
  -H "Accept: application/json"

SOS Inventory

Provider idsosinventory
Authorizehttps://api.sosinventory.com/oauth2/authorize
Tokenhttps://api.sosinventory.com/oauth2/token
Client authForm fields in the body
Scope bundlesSOS has no scopes. The one bundle, account, sends none: a connection can do whatever the SOS user who signs in can, so connect as a user whose SOS permissions match what the agent should do
Allowed hostsapi.sosinventory.com (the API is under /api/v2/)
Refresh tokenIssued at consent and used on every expiry. Treated as rotating, which is safe either way

Deployment setup

Register an app on developer.sosinventory.com (“Register your applications”) with this redirect URL:

https://<your TAP proxy host>/oauth/sosinventory/callback

Then set:

SOSINVENTORY_OAUTH_CLIENT_ID=...
SOSINVENTORY_OAUTH_CLIENT_SECRET=...
# Optional — only if the callback URL differs from {TAP_PROXY_URL}/oauth/sosinventory/callback
SOSINVENTORY_OAUTH_REDIRECT_URI=https://proxy.example/oauth/sosinventory/callback

An SOS account owner can see and revoke the connection in SOS under Tools & settings, Configuration, Connections.

HubSpot

Provider idhubspot
Authorizehttps://app.hubspot.com/oauth/authorize
Tokenhttps://api.hubapi.com/oauth/v3/token (v3; HubSpot has deprecated /oauth/v1/token)
Client authForm fields in the body. HubSpot’s v3 endpoint refuses credentials in the URL
Scope bundlesBoth bundles also request oauth, which a HubSpot app requires. crm-readwrite (the default): crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.companies.read, crm.objects.companies.write, crm.objects.deals.read, crm.objects.deals.write. crm-read: the three .read scopes only
Allowed hostsapi.hubapi.com
Refresh tokenDoes not expire and is not rotated; revoked when the app is uninstalled from the HubSpot account. The refresh request carries no scope
Access token30 minutes

Deployment setup

Create an app in a HubSpot developer account, and register this redirect URL on its Auth settings exactly as the proxy will send it:

https://<your TAP proxy host>/oauth/hubspot/callback

HubSpot treats every scope ticked on the app as required, and shows the person an error when a consent URL leaves one out. Tick the three .read scopes as required and the three .write scopes as conditionally required. Both bundles are then valid consent URLs: crm-read asks for the required set, and crm-readwrite adds the conditional ones.

Then set:

HUBSPOT_OAUTH_CLIENT_ID=...
HUBSPOT_OAUTH_CLIENT_SECRET=...
# Optional — only if the callback URL differs from {TAP_PROXY_URL}/oauth/hubspot/callback
HUBSPOT_OAUTH_REDIRECT_URI=https://proxy.example/oauth/hubspot/callback

Until both are set, /agent/connectors reports HubSpot available: false, naming the missing variable, and every start endpoint answers 503.

Calling the API

curl -X POST "$TAP_PROXY_URL/forward" \
  -H "X-TAP-Key: $TAP_AGENT_KEY" \
  -H "X-TAP-Credential: hubspot" \
  -H "X-TAP-Target: https://api.hubapi.com/crm/v3/objects/deals?limit=10&properties=dealname,dealstage,amount" \
  -H "X-TAP-Method: GET"

Facebook

Provider idfacebook
LoginFacebook Login for Business — https://www.facebook.com/v25.0/dialog/oauth
Tokenhttps://graph.facebook.com/v25.0/oauth/access_token
Client authForm fields
ScopesNone on the URL: the login configuration (config_id) sets the permissions
Allowed hostsgraph.facebook.com, rupload.facebook.com
Access tokenA system-user token that does not expire (a user-token configuration is exchanged for a 60-day token)
Page tokensFetched at consent from /me/accounts, and injected when a request’s path names that Page

Deployment setup

In Meta for Developers, create a Business app and add Facebook Login for Business. Create a login configuration with token type System-user access token, the Pages asset (and Instagram accounts, to reach them through Facebook), and the permissions you need — typically pages_show_list, pages_read_engagement and pages_manage_posts, plus instagram_basic and instagram_content_publish for linked Instagram accounts. Copy the configuration id.

Register TAP’s callback as a valid OAuth redirect URI:

https://<your TAP proxy host>/oauth/facebook/callback

Then set:

FACEBOOK_OAUTH_CLIENT_ID=...      # the Meta app id
FACEBOOK_OAUTH_CLIENT_SECRET=...  # the Meta app secret
FACEBOOK_OAUTH_CONFIG_ID=...      # the login configuration id

Until all three are set, /agent/connectors reports Facebook available: false, naming the first missing variable.

Only people with a role on the Meta app can sign in until Meta grants Advanced Access for each permission through App Review, which requires Business Verification. That review is what lets another company’s people connect their Pages.

Calling the API

The credential’s /agent/services entry lists the connected Pages as page_ids, and their linked Instagram accounts as instagram_business_account_ids. Name a Page in the path and TAP sends that Page’s token:

curl -X POST "$TAP_PROXY_URL/forward" \
  -H "X-TAP-Key: $TAP_AGENT_KEY" \
  -H "X-TAP-Credential: facebook" \
  -H "X-TAP-Target: https://graph.facebook.com/v25.0/$PAGE_ID/feed" \
  -H "X-TAP-Method: POST" \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello from our agent"}'

Any other path — /me, an Instagram account id — gets the system-user token. Pages are captured at consent, so after granting another Page, reconnect.

Every access_token field in a response is redacted, so /me/accounts or ?fields=access_token never hands the agent a token. Resumable Page video uploads to rupload.facebook.com name no Page in their path and are not supported yet; publish video by file_url instead.

Instagram

Provider idinstagram
LoginInstagram API with Instagram Login — https://www.instagram.com/oauth/authorize
Tokenhttps://api.instagram.com/oauth/access_token, then https://graph.instagram.com/access_token for the long-lived token
Client authForm fields
Scope bundlesbasic (instagram_business_basic), publish (+ instagram_business_content_publish), comments (+ instagram_business_manage_comments), messages (+ instagram_business_manage_messages); default basic and publish
Allowed hostsgraph.instagram.com, rupload.facebook.com
Access token60 days. TAP extends it in place once fewer than 30 days remain, at most once a day

For Instagram professional (Business or Creator) accounts; no Facebook Page is needed. Every bundle includes instagram_business_basic, because Meta refuses to extend a token without it.

Deployment setup

In your Meta app, add Instagram → API setup with Instagram login, and register this redirect URI under its Business login settings:

https://<your TAP proxy host>/oauth/instagram/callback

Copy the Instagram app id and secret shown there — they are not the Meta app’s own id and secret — and set:

INSTAGRAM_OAUTH_CLIENT_ID=...
INSTAGRAM_OAUTH_CLIENT_SECRET=...

As with Facebook, accounts outside the app’s own roles need Advanced Access through App Review.

Calling the API

curl -X POST "$TAP_PROXY_URL/forward" \
  -H "X-TAP-Key: $TAP_AGENT_KEY" \
  -H "X-TAP-Credential: instagram" \
  -H "X-TAP-Target: https://graph.instagram.com/v25.0/me?fields=user_id,username" \
  -H "X-TAP-Method: GET"

If the token lapses anyway — access revoked, a password change — /forward answers 502 with error_code: "oauth_reauth_required" and a reauth_url.

Adding a provider

  1. Add a row to PROVIDERS in crates/tap-proxy/src/oauth_providers.rs. The table tests check every row for the invariants the handlers assume — a non-empty host pin, default bundles that exist in the catalog, an id that is URL-safe and does not shadow google or microsoft.
  2. Name the row’s environment variables — the client id and secret, plus any authorize_param_vars — and set them on the deployment.
  3. Register the callback URL, {TAP_PROXY_URL}/oauth/{id}/callback, with the provider’s developer app.

There is no step 4. Routes, the connect-card listing, the consent flow, the callback, forward-time refresh and rotation write-back all read the row.