> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth

> Declare OAuth provider details; the transport owns the flow and calls onTokens to persist tokens.

OAuth lets users connect via a third-party authorization server. You declare the provider endpoints and scopes and name the client-credential secrets; the transport owns the flow — authorize redirect, state, callback, and code exchange. You persist the tokens in `onTokens()`.

## Basic OAuth

```ts theme={null}
import type { PartnerOAuthDeclaration } from '@ekanos/sdk/integration';

export const acmeOAuth: PartnerOAuthDeclaration<MyStorage> = {
  provider: {
    authorizationUrl: 'https://connect.acme.example/oauth/authorize',
    tokenUrl: 'https://connect.acme.example/oauth/token',
    scopes: ['payments:read', 'refunds:write'],
    pkce: true, // optional
  },

  credentials: {
    clientIdSecretName: 'acme_oauth_client_id',
    clientSecretSecretName: 'acme_oauth_client_secret',
  },

  async onTokens(ctx, tokens) {
    await ctx.secrets.set('acme_access_token', tokens.accessToken);

    if (tokens.refreshToken) {
      await ctx.secrets.set('acme_refresh_token', tokens.refreshToken);
    }

    ctx.logger.info(
      { scope: tokens.scope ?? null, expiresAt: tokens.expiresAt ?? null },
      'Stored Acme OAuth tokens.',
    );
  },
};

// In defineIntegration:
export const integration = defineIntegration({
  oauth: acmeOAuth,
  egress: [
    'https://connect.acme.example', // Both endpoints must be here
  ],
});
```

## Declaration

```ts theme={null}
oauth: {
  provider: {
    authorizationUrl: string;   // https only, in egress
    tokenUrl: string;           // https only, in egress
    scopes: string[];
    pkce?: boolean;
    tokenAuthMethod?: 'client_secret_basic' | 'client_secret_post'; // default 'client_secret_basic'
  },
  credentials: {
    clientIdSecretName: string;     // the secret name holding your OAuth client id
    clientSecretSecretName: string; // the secret name holding your OAuth client secret
  },
  async onTokens(ctx, tokens) { /* … */ },
}
```

## Token endpoint client authentication

`provider.tokenAuthMethod` says how the token endpoint expects the client to authenticate. It defaults to `'client_secret_basic'`: an HTTP Basic `Authorization` header. Set `'client_secret_post'` for a provider that wants `client_id` and `client_secret` in the form body instead and rejects Basic. LinkedIn is one. Check the provider's docs for which it accepts; a mismatch shows up as `exchange_failed`, and the provider's (redacted) response is in the host and harness logs.

## Endpoints

Both must be HTTPS and covered by your `egress` allowlist. `defineIntegration()` rejects the definition otherwise, telling you which origin to add.

```ts theme={null}
egress: [
  'https://connect.acme.example', // Covers both /authorize and /token
];
```

You do not declare a `redirectUri` — the schema is `.strict()` and rejects it. The host owns the callback route and builds the URI from its own origin.

The host serves three routes for every partner integration that declares `oauth`:

```
GET /api/integrations/<slug>/oauth/connect?accountId=…&returnPath=…
GET /api/integrations/<slug>/oauth/callback    (the provider redirects here)
GET /api/integrations/<slug>/oauth/status?accountId=…
```

Register the callback with your provider:

```
https://<your-ekanos-host>/api/integrations/<your-slug>/oauth/callback
```

The code exchange runs through your integration's allowlisted `ctx.fetch`, authenticating to the token endpoint with HTTP Basic client auth unless `provider.tokenAuthMethod` says otherwise, and then calls `onTokens(ctx, tokens)` with the real capability context.

## Client helpers

Reference the routes through `@ekanos/sdk/hooks` rather than spelling them yourself: pass `partnerOAuthConnectPath(slug)` as `OAuthActivationForm`'s `connectPath`, `createPartnerConnectionCheck(slug)` as its `checkConnection`, and `partnerOAuthErrorParam(slug)` as its `errorParam`. See [Activation form](/sdk/surfaces/activation-form#oauth-variant).

On failure the callback appends `<slug>_oauth_error=<code>` to the return URL. `partnerOAuthErrorParam` maps the codes to messages:

| Code | Message |
| - | - |
| `denied` | Access was denied at the provider. Try connecting again. |
| `provider_error` | The provider refused the connection request. Trying again won’t help; contact support if it keeps happening. |
| `expired` | The connection attempt expired. Try connecting again. |
| `invalid` | The connection attempt could not be verified. Try connecting again. |
| `unauthorized` | You need to be signed in as the person who started the connection. |
| `not_configured` | This integration is not configured for your workspace yet. Ask an administrator to add its OAuth credentials. |
| `exchange_failed` | The provider rejected the connection. Try again, or contact support if it keeps failing. |
| `save_failed` | Connected, but the credentials could not be saved. Try again. |
| `wrong_workspace` | You signed in to a different account or workspace than this one is set up for. Switch to that account at the provider and try again. |
| `app_not_set_up` | The provider refused this integration’s app settings, so trying again won’t help. Ask an administrator to check the app’s scopes and credentials. |
| `organization_access` | Connect with personal access — this integration only uses your own account. Try again and choose personal access. |

`wrong_workspace`, `app_not_set_up` and `organization_access` were added in 0.4.0. In short: `wrong_workspace` means the person consented in a different account or workspace than the integration is configured for; `app_not_set_up` means the provider refused the configured OAuth app (its client or scopes); `organization_access` means organization-wide access was granted where the integration only takes a person's own access. The apostrophes in those messages are typographic (’), so match ticket text accordingly.

## Tokens object

```ts theme={null}
{
  accessToken: string;
  refreshToken?: string;
  expiresAt?: string;    // ISO 8601
  scope?: string;
  tokenType?: string;
  raw?: Record<string, JsonValue>;  // Provider-specific extras, verbatim
}
```

All fields except `accessToken` are optional. `onTokens` runs after the code exchange. The contract also covers refreshes, but the host does not drive token refresh yet — see [Limitations](#limitations).

## Common patterns

### Store tokens separately

```ts theme={null}
async onTokens(ctx, tokens) {
  // Access token for immediate use
  await ctx.secrets.set('acme_access_token', tokens.accessToken);

  // Refresh token for later
  if (tokens.refreshToken) {
    await ctx.secrets.set('acme_refresh_token', tokens.refreshToken);
  }

  // Expiry info (declare 'sync_state/token-expiry' in your storage schemas)
  if (tokens.expiresAt) {
    await ctx.storage.account.set('sync_state/token-expiry', {
      expiresAt: tokens.expiresAt,
      refreshedAt: new Date().toISOString(),
    });
  }
}
```

### Log for audit

```ts theme={null}
async onTokens(ctx, tokens) {
  ctx.logger.info(
    { scope: tokens.scope, expiresAt: tokens.expiresAt },
    'Stored OAuth tokens.'
  );

  await ctx.secrets.set('access_token', tokens.accessToken);
}
```

## Limitations

Known gaps in the hosted flow:

* No host-driven token refresh
* No partner disconnect hook
* Token-endpoint client authentication is HTTP Basic or `client_secret_post` only (no `private_key_jwt`)
* No per-account secret path for OAuth-form integrations

The harness runs a local OAuth loop in live mode — see [Live mode](/harness/live-mode). `onTokens` is also an ordinary async function, so you can unit-test it directly against `createMockContext()`:

```ts theme={null}
import { createMockContext } from '@ekanos/sdk/testing';

const ctx = createMockContext({ integration: { slug: 'acme-payments' } });

await acmeOAuth.onTokens(ctx, {
  accessToken: 'test_token',
  expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
});

expect(await ctx.secrets.get('acme_access_token')).toBe('test_token');
```

## Troubleshooting

### Endpoint origin not covered by egress

`defineIntegration()` reports, for example, `oauth.provider.tokenUrl: its origin https://connect.acme.example is not covered by this definition's egress list [...]` and names the origin to add. Both `provider.authorizationUrl` and `provider.tokenUrl` must have their origins in your `egress` list:

```ts theme={null}
egress: ['https://connect.acme.example']; // Covers both
```

### Connect fails with not\_configured

The host could not resolve the client credentials named by `clientIdSecretName` / `clientSecretSecretName`. Those names are strings you choose; the values are credentials an administrator adds for your workspace, not fields a user enters. Ask an administrator to add them under the exact names your declaration uses.

## Next steps

* [Activation form](/sdk/surfaces/activation-form) — the `OAuthActivationForm` variant
* [Capability context](/sdk/capability-context) — use ctx in onTokens


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.