OAuth Authentication
Add OAuth 2.0 authentication to your workflows using the provider-agnostic @loopstack/oauth-module. Register providers like Google Workspace or GitHub, and access OAuth-protected APIs from any workflow or tool.
How It Works
The module is split into three layers:
- Provider registry — provider modules (Google, GitHub, etc.) implement
OAuthProviderInterfaceand self-register at module init. The registry resolves a provider by name at call time. - OAuth workflow — a generic
OAuthWorkflowthat builds the auth URL, surfaces a sign-in prompt viaOAuthPromptDocument, waits for the browser callback, and exchanges the auth code for tokens. Run it as a sub-workflow from any parent workflow. - Token store —
OAuthTokenStorepersists access/refresh tokens per user per provider in Redis (falls back to in-memory when Redis is unavailable).getValidAccessToken()automatically refreshes expired tokens via the provider’srefreshToken()method.
start ──► initiateOAuth ──► awaiting_auth ──► exchangeToken ──► end
│ │
│ Builds auth URL, │ Validates CSRF state,
│ saves OAuthPromptDocument │ exchanges code,
│ with sign-in prompt │ stores tokens
▼ ▼
(waits for user to (callback resumes
complete OAuth in browser) parent workflow)Tools that need an access token call OAuthTokenStore.getValidAccessToken(userId, provider) and return { error: 'unauthorized' } if no valid token exists, which lets the parent workflow guard branch into running OAuthWorkflow.
Setup
import { Module } from '@nestjs/common';
import { GoogleWorkspaceModule } from '@loopstack/google-workspace-module';
@Module({
imports: [GoogleWorkspaceModule],
providers: [MyWorkflow],
exports: [MyWorkflow],
})
export class MyModule {}GoogleWorkspaceModule and GitHubModule import OAuthModule internally — you don’t need to add it explicitly. For a custom provider, import OAuthModule directly alongside your provider module:
import { OAuthModule } from '@loopstack/oauth-module';
import { MyCustomOAuthModule } from './my-custom-oauth.module';
@Module({
imports: [OAuthModule, MyCustomOAuthModule],
providers: [MyWorkflow],
})
export class MyModule {}OAuthModule is decorated with @Global(), so a single import makes the registry, token store, and OAuth tools available throughout your app.
OAuth as Sub-Workflow
The simplest approach: launch the built-in OAuthWorkflow when authentication is needed.
import { BaseWorkflow, CallbackSchema, Guard, Transition, Workflow } from '@loopstack/common';
import type { RunContext } from '@loopstack/common';
import { MarkdownDocument } from '@loopstack/common';
import { OAuthWorkflow } from '@loopstack/oauth-module';
interface CalendarState {
events?: CalendarEvent[];
requiresAuthentication?: boolean;
}
@Workflow({ widget: __dirname + '/calendar.ui.yaml' })
export class CalendarWorkflow extends BaseWorkflow<{ calendarId: string }, CalendarState> {
constructor(
private readonly calendarFetchEvents: CalendarFetchEventsTool,
private readonly oAuth: OAuthWorkflow,
) {
super();
}
@Transition({ to: 'calendar_fetched' })
async fetchEvents(state: CalendarState, ctx: RunContext): Promise<CalendarState> {
const args = ctx.args as { calendarId: string };
const result = await this.calendarFetchEvents.call({
calendarId: args.calendarId,
});
return {
...state,
requiresAuthentication: result.data!.error === 'unauthorized',
events: result.data!.events,
};
}
// If unauthorized -> launch OAuth sub-workflow
@Transition({ from: 'calendar_fetched', to: 'awaiting_auth', priority: 10 })
@Guard('needsAuth')
async authRequired(state: CalendarState): Promise<CalendarState> {
await this.oAuth.run(
{ provider: 'google', scopes: ['https://www.googleapis.com/auth/calendar.readonly'] },
{ callback: { transition: 'authCompleted' }, show: 'inline', label: 'Google authentication required' },
);
return state;
}
needsAuth(state: CalendarState): boolean {
return !!state.requiresAuthentication;
}
// After auth -> retry from start
@Transition({ from: 'awaiting_auth', to: 'start', wait: true, schema: CallbackSchema })
async authCompleted(state: CalendarState, _payload: { workflowId: string }): Promise<CalendarState> {
return state;
}
// Success -> display results
@Transition({ from: 'calendar_fetched', to: 'end' })
async displayResults(state: CalendarState): Promise<unknown> {
await this.documentStore.save(MarkdownDocument, {
markdown: this.render(__dirname + '/templates/summary.md', { events: state.events }),
});
return {};
}
}Using Tokens in Custom Tools
import { z } from 'zod';
import { BaseTool, Tool, ToolResult } from '@loopstack/common';
import type { RunContext } from '@loopstack/common';
import { OAuthTokenStore } from '@loopstack/oauth-module';
@Tool({
name: 'calendar_fetch_events',
description: 'Fetches Google Calendar events.',
schema: z.object({ calendarId: z.string().default('primary') }).strict(),
})
export class CalendarFetchEventsTool extends BaseTool {
constructor(private readonly tokenStore: OAuthTokenStore) {
super();
}
protected async handle(args: { calendarId: string }, ctx: RunContext): Promise<ToolResult> {
const accessToken = await this.tokenStore.getValidAccessToken(ctx.userId, 'google');
if (!accessToken) {
return { data: { error: 'unauthorized' } };
}
const response = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${args.calendarId}/events`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return { data: await response.json() };
}
}Creating a Custom OAuth Provider
See Creating OAuth Providers for how to implement OAuthProviderInterface and register a custom provider.
Environment Variables
Each OAuth provider reads its own credentials from env, conventionally named <PROVIDER>_CLIENT_ID, <PROVIDER>_CLIENT_SECRET, and <PROVIDER>_OAUTH_REDIRECT_URI:
# Google Workspace
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_OAUTH_REDIRECT_URI=...
# GitHub
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GITHUB_OAUTH_REDIRECT_URI=...The token store also reads REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD — see Configuration Reference for defaults. Redis is optional; an in-memory fallback is used when unavailable.
Token Lifecycle
OAuthWorkflowgenerates auth URL and shows it to the user- User completes OAuth in browser
- Token is exchanged and stored per user per provider
OAuthTokenStore.getValidAccessToken()auto-refreshes expired tokens- Tools return
{ error: 'unauthorized' }if no token exists - Workflow guard detects the error and launches OAuth sub-workflow
Registry References
- google-oauth-example — Google Calendar fetch with OAuth sub-workflow, custom calendar tool, and Google Workspace agent with tool calling
- github-oauth-example — GitHub OAuth integration with repos overview and GitHub agent with 25+ tools