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
- 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"],
)- 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:
- verifies the signed
state; - resolves your client credentials from this app's app-scope secrets
{provider}_client_id/{provider}_client_secret(never from env); - exchanges the
codefor tokens at the provider's token endpoint; - 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 byopenid/email), then GmailgetProfile(for apps that declaregmail.*but notopenid). Microsoft uses Graph/me, Yahoo its userinfo; - 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; - 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)
| arg | meaning |
|---|---|
provider | one of the built-in providers: "google", "microsoft", "yahoo". |
collection | the 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. |
scopes | the 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)
- Save the client credentials as app-scope secrets in the Developer Portal → Secrets:
{provider}_client_idand{provider}_client_secret(e.g.google_client_id). Declare them with@ext.secret(scope="app")so one developer-owned key serves every user. - 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 yieldsredirect_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.")
raisewith_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:
| Trap | What actually happens |
|---|---|
if expires_at and now > expires_at | An account saved without expiry metadata never refreshes — it 401s forever and the user is told to reconnect, over and over. |
| Refreshing only on expiry | Clock 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_token | Microsoft 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/emailis 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 validrefresh_tokensat unused while users were told to reconnect hourly.
@ext.secret reference
@ext.secret reference — declare and access per-user credentials safely: API keys, OAuth tokens, and webhook signing secrets, encrypted at rest and never logged.
Experimental decorators reference
Experimental decorators reference — @ext.widget, @ext.expose and @ext.oauth_provider preview APIs, their stability caveats, and when it is safe to ship them.