Set up a per-user OAuth 2.0 connector

Last validated:

Aperture connectors are currently in alpha.

Some external services, such as Google Workspace, Atlassian, and Salesforce, require each user to authorize individually before Aperture can act on their behalf. Aperture's oauth2_authorization_code auth type handles this by running an OAuth 2.0 authorization code flow with Proof Key for Code Exchange (PKCE) (S256) for every user. Tokens are stored per-user so that credentials are never shared across users.

Prerequisites

Before you begin, you must meet the following requirements:

  • You have an Aperture instance with at least one provider configured.
  • You have admin access to the Aperture dashboard.
  • You have the connectors feature flag enabled in your Aperture configuration. Add "flags": {"connectors": {"value": true}} if you have not already.
  • You have an OAuth 2.0-capable account at the external provider (Google, Atlassian, Salesforce, or similar) with permission to register an OAuth application.
  • You have the Model Context Protocol (MCP) server URL for the remote service you want to connect.

Step 1: Register an OAuth application at the provider

Create an OAuth 2.0 application at your external provider. During registration, configure the following:

  1. Set the redirect URI to:

    https://<aperture-hostname>/aperture/auth/<connector-id>/callback
    

    Replace <aperture-hostname> with your Aperture instance's Tailscale hostname and <connector-id> with the connector ID you plan to use in your Aperture configuration (for example, google or atlassian). Connector IDs must start with a letter and contain only letters and digits (matching [a-zA-Z][a-zA-Z0-9]*). Hyphens and underscores are not allowed.

  2. Enable PKCE with the S256 challenge method if the provider requires explicit opt-in. Aperture always uses PKCE (S256) for authorization code flows.

  3. Record the following values from the provider's application settings:

    • Client ID
    • Client secret (if issued, not required for PKCE-only public clients)
    • Authorization URL
    • Token URL
    • Required scopes

The following table lists the authorization and token URLs for common providers, along with any required auth_params:

ProviderAuth URLToken URLRequired auth_paramsRequired scopes for refresh tokensNotes
Googlehttps://accounts.google.com/o/oauth2/v2/authhttps://oauth2.googleapis.com/token{"access_type": "offline", "prompt": "consent"}(set via auth_params)Without access_type: offline, Google does not issue a refresh token.
Atlassianhttps://auth.atlassian.com/authorizehttps://auth.atlassian.com/oauth/token{"audience": "api.atlassian.com", "prompt": "consent"}offline_access (alongside scopes such as read:jira-work and read:confluence-content.all)Without offline_access, Atlassian does not issue a refresh token.
Salesforcehttps://login.salesforce.com/services/oauth2/authorizehttps://login.salesforce.com/services/oauth2/tokenNone requiredrefresh_tokenRefer to the example below. You can also use the org-specific domain (https://<org>.my.salesforce.com).

Step 2: Configure the connector in Aperture

Open the Settings page of the Aperture dashboard and add a connector entry using the oauth2_authorization_code auth type.

The following example configures a Google Workspace connector:

{
  "flags": {
    "connectors": {"value": true}
  },
  "connectors": {
    "servers": {
      "google": {
        "protocol": "mcp",
        "url": "https://mcp.google.example.com/v1/mcp",
        "auth": {
          "type": "oauth2_authorization_code",
          "client_id": "<your-client-id>",
          "client_secret": "<your-client-secret>",
          "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
          "token_url": "https://oauth2.googleapis.com/token",
          "scopes": ["https://www.googleapis.com/auth/spreadsheets.readonly"],
          "auth_params": {"access_type": "offline", "prompt": "consent"}
        }
      }
    }
  }
}

Many providers require a specific scope or auth_params value before they issue a refresh token. For Google, omitting access_type: offline means the provider does not issue a refresh token. Other providers require a scope instead: Atlassian needs offline_access, and Salesforce needs refresh_token. Check your provider's documentation. Without the correct setting, the connector appears to work initially, but silently stops working after the first access token expires (typically one hour).

The client_secret field is optional. If the provider supports PKCE-only public clients and does not issue a client secret, you can omit this field.

The following example configures a Salesforce connector:

{
  "connectors": {
    "servers": {
      "salesforce": {
        "protocol": "mcp",
        "url": "https://api.salesforce.com/platform/mcp/v1/platform/sobject-all",
        "auth": {
          "type": "oauth2_authorization_code",
          "client_id": "<your-salesforce-client-id>",
          "client_secret": "<your-salesforce-client-secret>",
          "auth_url": "https://login.salesforce.com/services/oauth2/authorize",
          "token_url": "https://login.salesforce.com/services/oauth2/token",
          "scopes": ["mcp_api", "refresh_token"]
        }
      }
    }
  }
}

Step 3: Grant users access to the connector's tools

Aperture is deny-by-default. Without grants, users cannot access any connector tools, even after authorizing. Add grants in the grants section of your configuration.

The following example grants all users access to all tools and resources from the google connector, plus access to the built-in Aperture tools:

{
  "grants": [
    {
      "src": ["*"],
      "app": {
        "tailscale.com/cap/aperture": [
          {"connectors": ["aperture/**"]},
          {"connectors": ["google/**"]}
        ]
      }
    }
  ]
}

Connector grants use the connectors field with "connectorID/category/resource" FQN glob patterns. For example, "google/tools/*" grants access to all tools from the google connector, while "google/**" grants access to all capabilities (tools, resources, templates).

Refer to grant access to MCP tools for detailed instructions on configuring grants.

Step 4: Walk users through the authorization flow

Each user must complete an individual authorization flow before they can use the connector. Aperture identifies the calling user from their Tailscale identity, so the request must originate from a device signed in to your tailnet as that user. No token or API key is sent in the request. The per-user authorization binds to the Tailscale identity that makes this call. The same user must both start and complete the flow: if a different Tailscale user opens the authorization URL, the callback fails. An admin cannot authorize on another user's behalf.

The flow works as follows:

  1. The user sends a POST request to start the authorization:

    curl -X POST https://<aperture-hostname>/api/connectors/google/connect
    

    Aperture returns a JSON response containing the provider's authorization URL:

    {"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&code_challenge=...&code_challenge_method=S256&..."}
    
  2. The user opens the auth_url in a browser and signs in to the external provider.

  3. The provider displays a consent screen showing the requested scopes. The user grants consent.

  4. The provider redirects the user to https://<aperture-hostname>/aperture/auth/google/callback. Aperture renders an HTML page confirming success or failure. If the user declines consent, the page reports that the authorization was canceled, and they must restart the flow. Other provider errors, such as an admin-restricted app, are shown with the provider's error message.

  5. Aperture stores the token for that user. Subsequent tool calls from that user are automatically authenticated.

Pending authorization flows expire after 15 minutes. If the user does not complete the consent flow within this window, they must start a new flow by calling POST /api/connectors/<id>/connect again.

Step 5: Verify tools appear

After at least one user has authorized, tools from the connector appear in tools/list responses. Connectors using oauth2_authorization_code populate their tool catalog lazily: Aperture fetches tools on the first authenticated request after startup rather than at startup. This means tools from a per-user connector do not appear until at least one user has completed the authorization flow.

To verify tools are available, send a tools/list request:

curl https://<aperture-hostname>/v1/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'

The response should include tools prefixed with the connector ID (for example, google_search, google_query). Make this request as a user who has both completed the authorization flow and has a matching connector grant (refer to Step 3). A user who has authorized but lacks a grant cannot access the connector's tools.

Check or reset a user's authorization

To check whether a user is authorized, send a GET request to /api/connectors. As with the authorization request, Aperture identifies the user from their Tailscale identity, so the request must come from a device signed in as that user. The response reports a per-user status for each connector:

StatusMeaning
readyThe user is authorized and can use the connector.
needs_authThe user has access but has not authorized yet. They must complete the authorization flow.
misconfiguredA token is stored, but the OAuth app did not return a refresh token. Add the required scope or auth_params (refer to Step 2) and have the user reconnect.
no_accessThe user has not been granted access to the connector. Add a grant (refer to Step 3).

To clear a user's stored credential so they can re-authorize, send a POST request to the connector's disconnect endpoint as that user:

curl -X POST https://<aperture-hostname>/api/connectors/google/disconnect

After disconnecting, the user can start a new authorization flow.

Troubleshooting

Use the following sections to diagnose and resolve common issues with per-user OAuth 2.0 connectors.

Authorization flow expires

Pending authorization flows expire after 15 minutes. If a user reports that the authorization URL no longer works, have them start a new flow by calling POST /api/connectors/<id>/connect.

Tools stop working after a period of inactivity

If tools from a per-user connector stop working after a period of inactivity, the refresh token has likely expired or been revoked by the provider. Aperture treats tokens as expired 60 seconds early and refreshes them on the next request, but when a refresh fails, Aperture deletes the stored credential. The affected user must re-authorize by calling POST /api/connectors/<id>/connect again.

Missing refresh-token setting causes silent token expiry

If a provider does not issue a refresh token, the connector works initially but stops working after the access token expires (typically one hour). Each provider has its own requirement:

  • Google requires auth_params with {"access_type": "offline", "prompt": "consent"}. The access_type: offline value is what enables refresh tokens. prompt: consent ensures a refresh token is re-issued on subsequent authorizations.
  • Atlassian requires the offline_access scope.
  • Salesforce requires the refresh_token scope.

Add the required setting to your configuration and have affected users re-authorize. If a user's connector status is misconfigured, the stored token has no refresh token. Fix the configuration and have the user disconnect and reconnect.

Auth configuration rejected with unknown field error

The auth block uses strict field validation. If a field name is misspelled (for example, "secert" instead of "secret"), the configuration fails to load with a JSON decoder error. Double-check field names against the connectors reference.

Callback shows "authorization failed"

If the provider redirects to the callback URL but the user sees an "authorization failed" page:

  1. Verify that the redirect URI registered at the provider exactly matches https://<aperture-hostname>/aperture/auth/<connector-id>/callback.
  2. Confirm the state parameter was not modified during the redirect. This can occur if a proxy or browser extension alters query parameters.
  3. Confirm that the same Tailscale user who started the flow is the one completing it. If a different user opens the authorization URL, the callback fails.
  4. Check the Aperture server logs for detailed error information.

Next steps