Imperal Docs
SDK Reference

ext.oauth reference

ext.oauth + ctx.oauth_authorize_url — let the platform run the whole OAuth connect flow: declare a provider, build the authorize URL, save accounts automatically.

ext.oauth(...) hands the entire OAuth "connect an account" flow to the platform. You declare which providers your extension connects (Google, Microsoft, Yahoo, …), and the platform runs the dance end to end: it builds the authorize URL, exchanges the code, fetches the account email, saves a standard account record, and renders the result window. You write no OAuth code — no webhook, no token-exchange, no cron, no profile call.

This replaces the old pattern of hand-rolling an @ext.webhook("callback") + a polling schedule. Don't do that anymore — it's error-prone (scope mistakes, raw-JSON windows, deferred account creation) and duplicates what the platform already does.

The two pieces

  1. Declare the provider (once, at module scope):
from imperal_sdk import Extension

ext = Extension("mail", version="1.0.0")

ext.oauth(
    "google",
    collection="gmail_accounts",
    scopes=["https://www.googleapis.com/auth/gmail.modify"],
)
  1. Return the authorize URL from your connect handler:
@chat.function("connect_account", action_type="write")
async def connect_account(ctx, params):
    url = await ctx.oauth_authorize_url(params.provider)   # "google" | "microsoft" | "yahoo"
    return ActionResult.ok(data={"authorize_url": url})

That's it. The user's browser opens url, authorizes, and the platform redirects to its own callback, saves the account, and bounces the user back to your panel.

What the platform does (the callback)

The platform owns one generic route — GET /v1/ext/{app_id}/oauth/{provider}/callback — for every extension. On the provider's redirect it:

  1. verifies the signed state;
  2. resolves your client credentials from this app's app-scope secrets {provider}_client_id / {provider}_client_secret (never from env);
  3. exchanges the code for tokens at the provider's token endpoint;
  4. resolves the account email. One provider serves extensions with very different scope sets, so the platform tries that provider's identity endpoints in order and takes the first that answers — for google, OIDC userinfo first (covered by openid/email), then Gmail getProfile (for apps that declare gmail.* but not openid). Microsoft uses Graph /me, Yahoo its userinfo;
  5. saves a standard account record to your collection — only if the email actually resolved. If no endpoint answers, the callback fails visibly instead of saving a placeholder account your extension would have to filter out later;
  6. renders a branded "connected" page that auto-redirects to https://panel.imperal.io/ext/{app_id}?connected=1.

The account is saved synchronously — it appears the moment the user lands back on your panel. No cron, no "appears within a minute".

ext.oauth(provider, *, collection=None, scopes=None)

argmeaning
providerone of the built-in providers: "google", "microsoft", "yahoo".
collectionthe store collection the account record is written to. Defaults to f"{provider}_accounts". Use one shared collection (e.g. "gmail_accounts") if your extension lists all providers' accounts together.
scopesthe OAuth scopes to request for this provider.

The standard saved record is {email, provider, access_token, refresh_token, expires_at, is_active} — the first account for a user is set active; later ones are inactive until the user switches.

await ctx.oauth_authorize_url(provider, *, login_hint=None)

Builds the provider authorize URL. Reads the public client_id from your app-scope secret, the scopes from your ext.oauth(...) declaration, sets redirect_uri to the platform callback, and attaches a signed state. Return it from your connect() handler — never hardcode scopes, redirect_uri, or client_id.

Required setup (owner / operator, once)

  1. Save the client credentials as app-scope secrets in the Developer Portal → Secrets: {provider}_client_id and {provider}_client_secret (e.g. google_client_id). Declare them with @ext.secret(scope="app") so one developer-owned key serves every user.
  2. Register the redirect URI in the provider's developer console (Google Cloud, Azure AD, Yahoo): https://panel.imperal.io/v1/ext/{app_id}/oauth/{provider}/callback. A mismatch yields redirect_uri_mismatch.

Token refresh

Do not hand-roll this. Wrap your API call in with_fresh_token and the SDK keeps the token alive:

from imperal_sdk.oauth_tokens import with_fresh_token, TokenRefreshError

async def call(account):                 # receives an account with a LIVE access_token
    return await ctx.http.get(
        "https://analyticsdata.googleapis.com/v1beta/properties/123:runReport",
        headers={"Authorization": f"Bearer {account['access_token']}"},
    )

try:
    response = await with_fresh_token(
        ctx, account, call,
        provider="google",
        collection="google_analytics_accounts",   # where the account record lives
    )
except TokenRefreshError as exc:
    if exc.reconnect_required:           # revoked grant — the ONLY case that needs a reconnect
        return ActionResult.error("Reconnect the Google account.")
    raise

with_fresh_token(ctx, account, call, *, provider="google", collection="", skew=60) refreshes before the call when the token is inside the skew window, retries once on a 401, persists a rotated refresh_token, and writes the new token back through ctx.store — using the same app-scope {provider}_client_id / {provider}_client_secret you already declared. call receives the (possibly refreshed) account dict and must return a response exposing status_code.

Available from SDK 5.11.0. Lower-level pieces are exported too: needs_refresh(account) and fresh_token(ctx, account, ...) if you need the refreshed account without wrapping a call.

Why not write the POST yourself

Four extensions did, by hand-copying the same ~50 lines. All four drifted apart, and each rediscovered the same three traps the hard way:

TrapWhat actually happens
if expires_at and now > expires_atAn account saved without expiry metadata never refreshes — it 401s forever and the user is told to reconnect, over and over.
Refreshing only on expiryClock skew, early revocation and providers expiring sooner than advertised all produce a 401 on a token that looks valid. With no reactive retry, that is a manual reconnect.
Ignoring the returned refresh_tokenMicrosoft rotates it. Keep replaying the retired one and the account dies on its own schedule.

Handle only the terminal case yourself. TokenRefreshError.reconnect_required is True when the grant is genuinely revoked (invalid_grant) or no refresh token was ever stored — that user really must reconnect. A network blip or a provider 5xx is transient: the SDK falls back to the stored token rather than pushing the user through a needless reconnect.

Google returns a refresh_token on the first consent only — a later re-connect of the same account usually comes back without one. The platform therefore leaves the stored refresh_token untouched when a re-connect does not carry a new one, so reconnecting never downgrades a working account. Treat a missing refresh_token on re-connect as normal, not as an error.

Don't

  • Don't add a provider scope you do not otherwise need just to make the email resolvable. The platform tries each identity endpoint a provider knows about and takes the first that answers, so a Drive-only or Analytics-only app needs no gmail.* scope — openid/email is enough. Every extra scope you request is one more permission the user must approve on the consent screen.
  • Don't put client creds in env or in code — they live in app-scope secrets.
  • Don't build the callback yourself with @ext.webhook — the platform owns /v1/ext/{app_id}/oauth/{provider}/callback.
  • Don't hand-roll the refresh POST, and don't copy one out of another extension. Use with_fresh_token. Every hand-copied version in this codebase drifted, and the one extension that never got a copy shipped a bug where a valid refresh_token sat unused while users were told to reconnect hourly.

On this page