Call external APIs as the user
Signing a user in tells you who they are. Sometimes you also want to act as them somewhere else: read their Google Calendar, open a pull request on their GitHub, post to their Slack.
That always has two halves:
- Capture the external service's OAuth tokens once, stored
encrypted in an
OAuthTokenManager— one manager per external service. - Use them: fetch the tokens back and make the outbound call from a workflow.
How you capture depends on whose API you want to call.
Your identity provider's own API
If the API belongs to the provider your users already sign in with,
Reboot captures its tokens for you. Ask for the extra OAuth
scopes=[...] your calls need — on top of the identity scope the
provider always requests — and pass store_tokens=True:
def _google() -> Google:
return Google(
client_id=os.environ.get("GOOGLE_OAUTH_CLIENT_ID"),
client_secret=os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET"),
# Request the least you need.
scopes=["https://www.googleapis.com/auth/calendar.events"],
store_tokens=True,
)
oauth=OAuth(
provider=OAuthProviderByEnvironment(
# `Development()` issues no tokens, so to exercise the
# provider's API locally use the real provider in `dev=` too.
dev=_google(),
prod=_google(),
),
allowed_origins=["https://app.example.com"],
)
On each sign-in, Reboot captures the provider's access and refresh
tokens and stores them, encrypted, in the OAuthTokenManager for that
provider. Your code reads them back with
OAuthTokenManager.fetch,
as shown under Using the tokens below.
This needs the OAuthTokenManager library on your Application; see
Imports and servicers
for the lines to add.
Reboot stores only the provider's own tokens. This matters most
with a broker like Auth0 or Ory: when a user signs in "with
Google" through Auth0, what gets stored is an Auth0 token. It
authorizes Auth0's APIs, not Google's.
To call the upstream service in that case, either retrieve the
federated provider's token from the broker (Auth0 keeps it on the
user's profile, retrievable via its Management API with the
read:user_idp_tokens scope), or capture the upstream service's
tokens yourself, as described next.
Any other OAuth service
When the service is not your identity provider — your users sign in with Google but you want their Slack — there is no shortcut. A Google grant only authorizes Google's APIs.
Run the other service's OAuth flow yourself with your own HTTP
routes, then store what it issues in that service's
OAuthTokenManager, so that all of your application's OAuth
tokens live in the same kind of store:
- Register an authorize route that redirects the browser to the
service's authorize URL — your client ID, the scopes you need, your
callback as the redirect URI, and a signed
statecarrying the signed-in user's ID. - Register a callback route that verifies the
state, exchanges thecodeat the service's token endpoint, and callsOAuthTokenManager.storeunder a service ID of your choosing (e.g."slack.com").
OAuthTokenManager's methods are application-internal, so the
callback route must opt into an application-internal context:
# In `main()`, after constructing `application`:
application.http.get("/__/oauth/slack/authorize")(slack_authorize)
application.http.get("/__/oauth/slack/callback", app_internal=True)(
slack_callback
)
app_internal=True makes the route's calls count as your own
application's, so the default authorizer,
and any is_app_internal rule you wrote, lets them through. Only set
it on a route you are certain serves trusted traffic — here, a
callback that runs only after you have verified the OAuth state you
issued. Never set it on a route that acts on unvalidated request
input.
Using the tokens
Reading tokens back and calling the service is the same however they
were captured. The outbound HTTP call belongs in a
workflow, wrapped in
at_least_once or at_most_once
like any other side effect:
from rbt.std.oauth.v1.oauth_rbt import OAuthTokenManager
from reboot.aio.workflows import at_least_once
from reboot.std.oauth.v1.oauth import GOOGLE
@classmethod
async def create_event(
cls,
context: WorkflowContext,
request: User.CreateEventRequest,
) -> User.CreateEventResponse:
stored = await OAuthTokenManager.ref(GOOGLE).fetch(
context, user_id=context.state_id,
)
if not stored.found:
# Not connected yet: surface a "connect" path, don't crash.
return User.CreateEventResponse(connected=False)
async def do_create():
return await calendar_api.create_event(
access_token=stored.tokens.access_token,
summary=request.summary,
)
result = await at_least_once("Create event", context, do_create)
return User.CreateEventResponse(connected=True)
Services that use an API key instead
Some services have no per-user OAuth; the user gives your application an API key or personal access token instead.
This section is about a key the user provides so your application can act as that user — a per-user secret, stored per user. A key you, the developer, hold for the whole application (your Stripe secret key, your OpenAI key) is not per-user state and does not belong in application state at all: deliver it as an application secret via environment variables.
OAuthTokenManager is purpose-built for OAuth tokens; do not shoehorn
an API key into it. The key is still a secret at rest, so encrypt it
with Ciphertext and keep the
returned Ciphertext state ID — itself a harmless string — in your
state as the reference to the encrypted value:
ciphertext, _ = await Ciphertext.encrypt(
context,
plaintext=request.api_key.encode(),
associated_data=make_associated_data(
user_id=user_id, purpose="acme-api-key",
),
scope=f"user:{user_id}", # The crypto-shred unit.
key_manager_id=APP_SHARED_KEY_MANAGER_ID,
)
# The reference to the encrypted key; never the key itself.
self.state.acme_api_key_id = ciphertext.state_id
To use it, decrypt (with the same associated_data) and make the
call from a workflow, exactly as above.
Erasing a user's credentials
Both stores support per-user erasure by crypto-shredding: see
erasing a user's tokens
for OAuthTokenManager, and
crypto-shred for
Ciphertext-encrypted API keys.