Skip to Content

@loopstack/oauth-module

OAuth module for the Loopstack  automation framework.

A provider-agnostic OAuth 2.0 framework that handles the full authorization code flow — URL generation, code exchange, token storage, and automatic refresh. Any provider (Google, GitHub, Microsoft, etc.) can be plugged in by implementing a single interface.

When to Use

  • You need OAuth 2.0 authentication in your workflows (e.g. accessing Google Calendar, GitHub repos, or any third-party API)
  • You want a reusable sub-workflow that handles the entire auth flow (popup, code exchange, token storage) and resumes the parent workflow on completion
  • You are building a custom OAuth provider integration and need a standardized registration and token management layer
  • Use @loopstack/google-workspace-module or @loopstack/github-module directly if you only need Google or GitHub — they include their own providers and import OAuthModule internally

Installation

npm install @loopstack/oauth-module

Register the module in your NestJS module. OAuthModule is @Global(), so a single import makes its services available everywhere:

import { Module } from '@nestjs/common'; import { OAuthModule } from '@loopstack/oauth-module'; @Module({ imports: [OAuthModule], }) export class AppModule {}

Quick Start

Inject OAuthWorkflow into your workflow and launch it as a sub-workflow when authentication is needed:

import { BaseWorkflow, CallbackSchema, Guard, Transition, Workflow } from '@loopstack/common'; import type { RunContext } from '@loopstack/common'; import { OAuthWorkflow } from '@loopstack/oauth-module'; @Workflow({ schema: z.object({ calendarId: z.string().default('primary') }).strict(), }) 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, }; } @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; } @Transition({ from: 'awaiting_auth', to: 'start', wait: true, schema: CallbackSchema }) async authCompleted(state: CalendarState, _payload: { workflowId: string }): Promise<CalendarState> { return state; } @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 {}; } }

show: 'inline' (the default) renders the OAuth sub-workflow as an embedded iframe in the parent’s run view, so the user can complete authentication without leaving the page.

How It Works

Architecture

oauth-module (generic) provider module (e.g. google) ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ OAuthProviderRegistry │◄───│ GoogleWorkspaceOAuthProvider│ │ OAuthTokenStore │ │ (implements interface, │ │ BuildOAuthUrlTool │ │ registers on init) │ │ ExchangeOAuthTokenTool │ └──────────────────────────────┘ │ OAuthWorkflow │ │ OAuthPromptDocument │ consumer workflow └──────────────────────────────┘ ┌──────────────────────────────┐ │ uses OAuthTokenStore │ │ launches OAuthWorkflow │ │ via constructor injection │ └──────────────────────────────┘

The module is split into three layers:

  1. Provider registry — provider modules implement OAuthProviderInterface and self-register via OnModuleInit
  2. OAuth workflow — a generic workflow that builds the auth URL, shows a sign-in prompt, waits for the callback, then exchanges the code for tokens
  3. Token store — persists tokens per user per provider in Redis (falls back to in-memory if Redis is unavailable)

OAuthWorkflow State Machine

start ──► initiateOAuth ──► awaiting_auth ──► exchangeToken ──► end │ │ │ Builds auth URL via │ Validates CSRF state, │ BuildOAuthUrlTool, │ exchanges code via │ saves OAuthPromptDocument │ ExchangeOAuthTokenTool, │ with sign-in prompt │ stores tokens, updates │ │ document to 'success' ▼ ▼ (waits for user to (callback resumes complete OAuth in browser) parent workflow)

Token Lifecycle

  1. OAuthWorkflow calls BuildOAuthUrlTool to generate an auth URL with a CSRF state parameter
  2. The user completes OAuth in the browser popup
  3. The callback triggers exchangeToken, which validates the state and calls ExchangeOAuthTokenTool
  4. Tokens are stored per user per provider via OAuthTokenStore
  5. OAuthTokenStore.getValidAccessToken() automatically refreshes expired tokens using the provider’s refreshToken() method
  6. Tools return { error: 'unauthorized' } when no valid token exists, triggering the workflow guard

Using Tokens in Custom Tools

Inject OAuthTokenStore to access stored tokens:

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: 'my_api_fetch', description: 'Fetches data from an OAuth-protected API.', schema: z.object({ query: z.string() }).strict(), }) export class MyApiFetchTool extends BaseTool { constructor(private readonly tokenStore: OAuthTokenStore) { super(); } protected async handle(args: { query: string }, ctx: RunContext): Promise<ToolResult> { const accessToken = await this.tokenStore.getValidAccessToken(ctx.userId, 'my-provider'); if (!accessToken) { return { data: { error: 'unauthorized' } }; } const response = await fetch('https://api.example.com/data', { headers: { Authorization: `Bearer ${accessToken}` }, }); return { data: await response.json() }; } }

Args Reference

OAuthWorkflow

ArgTypeRequiredDescription
providerstringYesProvider ID (e.g. 'google', 'github')
scopesstring[]NoOAuth scopes to request (defaults to provider’s defaultScopes)

Run options:

OptionTypeDescription
callbackobject{ transition: string } — transition to call on the parent workflow when auth completes

Returns: { authenticated: boolean }

Tools Reference

build_oauth_url

Builds an OAuth 2.0 authorization URL for the given provider with a CSRF state parameter.

ArgTypeRequiredDescription
providerstringYesProvider ID
scopesstring[]YesOAuth scopes to request

Returns: { authUrl: string, state: string }

exchange_oauth_token

Exchanges an OAuth 2.0 authorization code for access and refresh tokens, validates the CSRF state, and stores the tokens globally for the user.

ArgTypeRequiredDescription
providerstringYesProvider ID
codestringYesAuthorization code from OAuth callback
statestringYesState parameter from callback
expectedStatestringYesExpected state for CSRF validation

Returns: { accessToken: string, refreshToken: string | undefined, expiresIn: number | undefined, scope: string | undefined }

Configuration

Redis (Token Storage)

OAuthTokenStore connects to Redis for persistent token storage. If Redis is unavailable, it falls back to in-memory storage.

Env VariableDefaultDescription
REDIS_HOSTlocalhostRedis host
REDIS_PORT6379Redis port
REDIS_PASSWORDRedis password

Tokens with refresh tokens are stored with a 30-day TTL. Access-only tokens expire based on their expiresIn value.

Service Reference

OAuthProviderRegistry

Manages registered OAuth providers at runtime.

MethodDescription
register(provider)Register a provider instance
get(providerId)Get a provider by ID (throws if missing)
has(providerId)Check if a provider is registered

OAuthTokenStore

Stores and retrieves OAuth tokens per user and provider. Uses Redis with in-memory fallback.

MethodDescription
storeTokens(userId, providerId, tokens)Store a StoredTokens object for a user/provider pair
storeFromTokenSet(userId, providerId, set)Store tokens from an OAuthTokenSet (auto-calculates expiry)
getTokens(userId, providerId)Get stored tokens (may be expired)
getValidAccessToken(userId, providerId)Get a valid access token, auto-refreshing if expired and possible

Provider Interface Reference

Implement OAuthProviderInterface to add a new OAuth provider:

interface OAuthProviderInterface { readonly providerId: string; // Unique identifier, e.g. 'google', 'github' readonly defaultScopes: string[]; // Fallback scopes when none are specified buildAuthUrl(scopes: string[], state: string): string; exchangeCode(code: string): Promise<OAuthTokenSet>; refreshToken(refreshToken: string): Promise<OAuthTokenSet>; } interface OAuthTokenSet { accessToken: string; refreshToken?: string; expiresIn: number; // Seconds until expiry scope: string; }
MethodPurpose
buildAuthUrlConstruct the OAuth authorization URL for the user to visit
exchangeCodeExchange the authorization code for tokens after redirect
refreshTokenRefresh an expired access token using the refresh token

The provider self-registers via NestJS OnModuleInit:

@Injectable() export class MyOAuthProvider implements OAuthProviderInterface, OnModuleInit { readonly providerId = 'my-provider'; readonly defaultScopes = ['read', 'write']; constructor(private readonly registry: OAuthProviderRegistry) {} onModuleInit(): void { this.registry.register(this); } buildAuthUrl(scopes: string[], state: string): string { /* ... */ } async exchangeCode(code: string): Promise<OAuthTokenSet> { /* ... */ } async refreshToken(refreshToken: string): Promise<OAuthTokenSet> { /* ... */ } }

Wrap it in a module that imports OAuthModule:

@Module({ imports: [OAuthModule], providers: [MyOAuthProvider], exports: [MyOAuthProvider], }) export class MyOAuthModule {}

Existing Providers

ProviderModuleProvider ID
Google@loopstack/google-workspace-module'google'
GitHub@loopstack/github-module'github'

Document Types

OAuthPromptDocument

Rendered by the oauth-prompt widget. Used internally by the OAuthWorkflow to show the sign-in prompt with a popup-based authentication flow.

FieldTypeDescription
providerstringProvider ID
authUrlstringThe OAuth authorization URL
statestringCSRF state parameter
status'pending' | 'success' | 'error'Current auth status
messagestring (optional)Status message

Public API

Module

  • OAuthModule — global NestJS module, registers all providers/services/tools/workflows

Services

  • OAuthProviderRegistry — runtime registry for OAuth providers
  • OAuthTokenStore — token persistence with Redis/in-memory fallback

Tools

  • BuildOAuthUrlTool (build_oauth_url) — generates authorization URLs
  • ExchangeOAuthTokenTool (exchange_oauth_token) — exchanges codes for tokens

Workflows

  • OAuthWorkflow — generic OAuth 2.0 authorization code flow

Documents

  • OAuthPromptDocument — sign-in prompt rendered by oauth-prompt widget

Contracts

  • OAuthProviderInterface — interface for pluggable OAuth providers
  • OAuthTokenSet — token response shape returned by providers
  • StoredTokens — internal token storage shape (includes expiresAt)

Dependencies

PackageRole
@loopstack/commonBase classes, decorators, types
@loopstack/coreWorkflow engine, sub-workflow support
ioredisRedis client for token storage
zodSchema validation

About

Author: Jakob Klippel 

License: MIT

Last updated on