2026 03 18 Jarvis P3 Oauth Mvp
Documentation for Plans
Jarvis Phase 3: OAuth Credential Delegation MVP — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable end-to-end OAuth flow: user connects Google account → Vitto agent calls Google APIs on user's behalf (e.g., read calendar, list drive files) — tokens encrypted at rest, never exposed to LLM context.
Architecture: backend-api (gograb-api) owns OAuth tokens. Consent flow: frontend opens popup → backend-api handles redirect → encrypts refresh token (AES-256-GCM) → stores in MongoDB. Agent needs data: vitto-api calls backend-api's proxy endpoint → backend-api decrypts token, calls Google, returns clean data. Redis caches connection status for fast lookups from vitto-api.
Tech Stack: NestJS, Mongoose, AES-256-GCM (Node.js crypto), Google OAuth 2.0, Redis (ioredis), PKCE
Spec: docs/superpowers/specs/2026-03-18-vitto-jarvis-comprehensive-evolution-design.md (Section 4)
Depends on: Redis deployed (confirmed), BackendApiClientService HTTP pattern (confirmed)
File Structure
New Files (backend-api)
| File | Responsibility |
|---|---|
libs/libs-api/backend-core/src/lib/schemas/user-oauth-connection.schema.ts |
Mongoose schema for encrypted OAuth tokens |
APIs/backend-api/src/app/auth/oauth/oauth.service.ts |
Token encryption, storage, refresh, proxy |
APIs/backend-api/src/app/auth/oauth/oauth.controller.ts |
HTTP endpoints: connect, callback, connections, disconnect, proxy |
APIs/backend-api/src/app/auth/oauth/providers/google.provider.ts |
Google-specific OAuth config |
APIs/backend-api/src/app/auth/oauth/oauth.module.ts |
NestJS module registration |
New Files (backend-vitto-api)
| File | Responsibility |
|---|---|
APIs/backend-vitto-api/src/app/tools/plugins/oauth.plugin.ts |
oauth_api_call + oauth_status tools for agent |
Modified Files
| File | Change |
|---|---|
APIs/backend-api/src/app/app.module.ts |
Import OAuthModule |
APIs/backend-vitto-api/src/app/tools/plugins/plugins.index.ts |
Register oauthPlugin |
APIs/backend-vitto-api/src/app/connectivity/backend-api-client.service.ts |
Add oauthProxy() method |
Task 1: UserOAuthConnection Schema
Files:
Create:
libs/libs-api/backend-core/src/lib/schemas/user-oauth-connection.schema.tsStep 1: Create the schema
/**
* UserOAuthConnection Schema
* S234 Phase 3: Stores encrypted OAuth tokens in gograb-api database.
*
* Security: refresh tokens encrypted with AES-256-GCM.
* Access tokens are ephemeral (1h) and never stored — refreshed on demand.
*/
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type OAuthProvider = 'google' | 'github' | 'slack' | 'notion';
export type OAuthConnectionStatus = 'active' | 'expired' | 'revoked' | 'requires_reauth';
@Schema({ collection: 'user_oauth_connections', timestamps: true })
export class UserOAuthConnection extends Document {
@Prop({ required: true, index: true })
userId: string;
@Prop({ required: true, type: String })
provider: OAuthProvider;
@Prop()
providerUserId?: string;
@Prop()
providerEmail?: string;
@Prop({ type: [String], default: [] })
scopes: string[];
/** AES-256-GCM encrypted refresh token */
@Prop({ required: true })
encryptedRefreshToken: string;
/** Per-token IV (base64) */
@Prop({ required: true })
encryptionIV: string;
/** Per-token GCM auth tag (base64) */
@Prop({ required: true })
encryptionAuthTag: string;
@Prop({ type: String, default: 'active' })
status: OAuthConnectionStatus;
@Prop()
tokenExpiresAt?: Date;
@Prop()
lastUsedAt?: Date;
@Prop()
lastRefreshedAt?: Date;
@Prop({ default: () => new Date() })
connectedAt: Date;
@Prop()
revokedAt?: Date;
/** Provider-specific metadata (e.g., QuickBooks realmId) */
@Prop({ type: Object, default: {} })
providerMetadata: Record<string, unknown>;
}
export const UserOAuthConnectionSchema = SchemaFactory.createForClass(UserOAuthConnection);
// Compound unique index: one connection per user per provider
UserOAuthConnectionSchema.index({ userId: 1, provider: 1 }, { unique: true });
// Query index for token health checks
UserOAuthConnectionSchema.index({ status: 1, tokenExpiresAt: 1 });
- Step 2: Export from backend-core
Find the backend-core barrel export (likely libs/libs-api/backend-core/src/index.ts or similar) and add:
export * from './lib/schemas/user-oauth-connection.schema';
- Step 3: Verify compilation
Run: npx tsc --noEmit --project libs/libs-api/backend-core/tsconfig.lib.json 2>&1 | head -10
- Step 4: Commit
git add libs/libs-api/backend-core/src/lib/schemas/user-oauth-connection.schema.ts
git commit -m "[S234.3] feat: add UserOAuthConnection schema for encrypted OAuth token storage"
Task 2: Google OAuth Provider Config
Files:
Create:
APIs/backend-api/src/app/auth/oauth/providers/google.provider.tsStep 1: Create the provider config
/**
* Google OAuth 2.0 Provider Configuration
* S234 Phase 3: Handles Google-specific OAuth quirks.
*
* Quirks: uses form-encoded token exchange, requires `prompt` param
* for consent, sensitive scope security review for production.
*/
export interface OAuthProviderConfig {
name: string;
authUrl: string;
tokenUrl: string;
revokeUrl?: string;
userInfoUrl?: string;
clientId: string;
clientSecret: string;
defaultScopes: string[];
/** Token exchange encoding: 'form' (standard) or 'json' (Notion) */
tokenEncoding: 'form' | 'json';
/** Extra query params for authorization URL */
authParams?: Record<string, string>;
}
export function getGoogleConfig(
clientId: string,
clientSecret: string,
): OAuthProviderConfig {
return {
name: 'google',
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
revokeUrl: 'https://oauth2.googleapis.com/revoke',
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
clientId,
clientSecret,
defaultScopes: [
'openid',
'email',
'profile',
'https://www.googleapis.com/auth/calendar.readonly',
],
tokenEncoding: 'form',
authParams: {
access_type: 'offline',
prompt: 'consent',
},
};
}
- Step 2: Commit
git add APIs/backend-api/src/app/auth/oauth/providers/google.provider.ts
git commit -m "[S234.3] feat: add Google OAuth provider configuration"
Task 3: OAuthService — Core Token Management
Files:
Create:
APIs/backend-api/src/app/auth/oauth/oauth.service.tsTest:
APIs/backend-api/src/app/auth/oauth/__tests__/oauth.service.spec.tsStep 1: Write the failing test
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config';
import { OAuthService } from '../oauth.service';
import { UserOAuthConnection } from '@libs-api/backend-core';
describe('OAuthService', () => {
let service: OAuthService;
const mockModel = {
findOne: jest.fn(),
create: jest.fn(),
findOneAndUpdate: jest.fn(),
findOneAndDelete: jest.fn(),
find: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OAuthService,
{ provide: getModelToken(UserOAuthConnection.name), useValue: mockModel },
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string, def?: string) => {
if (key === 'OAUTH_ENCRYPTION_KEY') return Buffer.alloc(32).toString('base64');
return def;
}),
},
},
{ provide: 'RedisService', useValue: { getClient: () => null, isConnected: () => false } },
],
}).compile();
service = module.get(OAuthService);
jest.clearAllMocks();
});
it('should encrypt and decrypt a token roundtrip', () => {
const token = 'ya29.test-refresh-token-value';
const encrypted = service.encryptToken(token);
expect(encrypted.ciphertext).not.toBe(token);
expect(encrypted.iv).toBeTruthy();
expect(encrypted.authTag).toBeTruthy();
const decrypted = service.decryptToken(encrypted.ciphertext, encrypted.iv, encrypted.authTag);
expect(decrypted).toBe(token);
});
it('should build correct authorization URL with PKCE', () => {
const { url, state, codeVerifier } = service.buildAuthorizationUrl(
'google', 'user-123', 'http://localhost:3000/auth/oauth/callback'
);
expect(url).toContain('accounts.google.com');
expect(url).toContain('code_challenge=');
expect(url).toContain('state=');
expect(state).toBeTruthy();
expect(codeVerifier).toBeTruthy();
});
it('should reject unknown providers', () => {
expect(() => service.buildAuthorizationUrl(
'unknown' as any, 'user-123', 'http://callback'
)).toThrow('Unknown provider');
});
});
- Step 2: Implement OAuthService
The service needs:
encryptToken(token)→{ ciphertext, iv, authTag }(AES-256-GCM)decryptToken(ciphertext, iv, authTag)→ plaintextbuildAuthorizationUrl(provider, userId, redirectUri)→{ url, state, codeVerifier }handleCallback(provider, code, state, codeVerifier)→ exchanges code for tokens, encrypts, storesgetConnections(userId)→ list user's connected servicesdisconnect(userId, provider)→ revoke + deleteproxyApiCall(userId, provider, method, endpoint, body?)→ decrypt token, refresh if needed, call API, return data- Redis caching: SET
oauth:{userId}:{provider}with status on connect, GET for fast status checks
Key implementation details:
PKCE: generate
code_verifier(random 43-128 chars), computecode_challenge= base64url(sha256(verifier))State: HMAC(userId + timestamp + random, secret) stored in Redis with 10-min TTL
Encryption key:
OAUTH_ENCRYPTION_KEYenv var (32 bytes base64)Provider configs loaded via factory pattern from env vars
Step 3: Run tests
Step 4: Commit
Task 4: OAuthController — HTTP Endpoints
Files:
Create:
APIs/backend-api/src/app/auth/oauth/oauth.controller.tsStep 1: Implement controller
5 endpoints:
POST /auth/oauth/connect → { authUrl } — returns URL for popup
GET /auth/oauth/callback → handles redirect, stores tokens, closes popup
GET /auth/oauth/connections → list user's connected services
DELETE /auth/oauth/connections/:provider → disconnect
POST /auth/oauth/proxy → { provider, method, endpoint, body? } → proxied API response
Security:
All endpoints require authentication (existing UnifiedAuthGuard)
/proxyvalidates userId matches authenticated userRate limiting:
@Throttle(10, 60)on connect/proxyStep 2: Commit
Task 5: OAuthModule + App Registration
Files:
Create:
APIs/backend-api/src/app/auth/oauth/oauth.module.tsModify:
APIs/backend-api/src/app/app.module.tsStep 1: Create module with Mongoose schema + Redis
@Module({
imports: [
MongooseModule.forFeature([
{ name: UserOAuthConnection.name, schema: UserOAuthConnectionSchema },
]),
],
controllers: [OAuthController],
providers: [OAuthService],
exports: [OAuthService],
})
export class OAuthModule {}
Step 2: Import in app.module.ts
Step 3: Commit
Task 6: BackendApiClientService — OAuth Proxy Method
Files:
Modify:
APIs/backend-vitto-api/src/app/connectivity/backend-api-client.service.tsStep 1: Add oauthProxy method
async oauthProxy(
userId: string,
provider: string,
method: string,
endpoint: string,
body?: Record<string, unknown>,
): Promise<Record<string, unknown> | null> {
try {
const { data } = await firstValueFrom(
this.http.post(`${this.baseUrl}/auth/oauth/proxy`, {
userId,
provider,
method,
endpoint,
body,
}, { headers: this.headers }),
);
return data;
} catch (err) {
this.logger.warn(`OAuth proxy failed: ${(err as Error).message}`);
return null;
}
}
async getOAuthConnections(userId: string): Promise<Array<{ provider: string; status: string; scopes: string[] }>> {
try {
const { data } = await firstValueFrom(
this.http.get(`${this.baseUrl}/auth/oauth/connections`, {
headers: { ...this.headers, 'x-user-id': userId },
}),
);
return data;
} catch {
return [];
}
}
- Step 2: Commit
Task 7: OAuth Tool Plugin for Vitto
Files:
Create:
APIs/backend-vitto-api/src/app/tools/plugins/oauth.plugin.tsModify:
APIs/backend-vitto-api/src/app/tools/plugins/plugins.index.tsStep 1: Create oauth.plugin.ts
Two tools:
oauth_status— check which services the user has connectedoauth_api_call— make authenticated API call on behalf of user
export function oauthPlugin(ctx: ToolPluginContext): ToolHandler[] {
return [
{
name: 'oauth_status',
description: 'Check which external services (Google, GitHub, etc.) the user has connected via OAuth.',
parameters: { type: 'object', properties: {}, required: [] },
layers: ['developer', 'platform_builder'],
execute: async (_input, context) => {
if (!context?.userId) return JSON.stringify({ error: 'No user context' });
// Call backend-api via BackendApiClientService
// ctx needs backendApiClient reference
return JSON.stringify({ connections: [] }); // TODO: wire
},
},
{
name: 'oauth_api_call',
description: [
'Make an authenticated API call to an external service on behalf of the user.',
'The user must have connected the service via OAuth first.',
'Example: oauth_api_call({ provider: "google", method: "GET",',
'endpoint: "https://www.googleapis.com/calendar/v3/calendars/primary/events" })',
].join(' '),
parameters: {
type: 'object',
properties: {
provider: { type: 'string', enum: ['google'], description: 'OAuth provider' },
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'HTTP method' },
endpoint: { type: 'string', description: 'Full API URL to call' },
body: { type: 'object', description: 'Request body for POST/PUT' },
},
required: ['provider', 'method', 'endpoint'],
},
layers: ['developer', 'platform_builder'],
execute: async (input, context) => {
if (!context?.userId) return JSON.stringify({ error: 'No user context' });
// Proxy through BackendApiClientService → backend-api → Google
return JSON.stringify({ error: 'OAuth proxy not yet wired' }); // TODO: wire
},
},
];
}
Step 2: Register in plugins.index.ts
Step 3: Commit
Post-Implementation Notes
What This Plan Delivers
- End-to-end OAuth flow: connect Google → agent reads calendar
- AES-256-GCM encrypted token storage in gograb-api database
- PKCE for security (no client_secret in authorization URL)
- Redis-cached connection status for fast lookups
- Two agent tools: oauth_status + oauth_api_call
What This Defers
- GitHub/Slack/Notion providers (same pattern, add config)
- Frontend Connected Services UI (agent-based connect works first)
- Token health cron (manual reconnect for MVP)
- Admin OAuth dashboard
Environment Variables Required
OAUTH_ENCRYPTION_KEY=<32-bytes-base64> # AES-256-GCM key for token encryption
GOOGLE_OAUTH_CLIENT_ID=<from-google-console>
GOOGLE_OAUTH_CLIENT_SECRET=<from-google-console>
OAUTH_CALLBACK_URL=http://localhost:3000/api/auth/oauth/callback
Security Invariants
- Refresh tokens NEVER enter agent/LLM context
- Access tokens are ephemeral (1h), never stored
- All proxy calls go through backend-api — vitto-api never holds credentials
- PKCE prevents authorization code interception
- State parameter prevents CSRF