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:

/**
 * 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 });

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';

Run: npx tsc --noEmit --project libs/libs-api/backend-core/tsconfig.lib.json 2>&1 | head -10

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:

/**
 * 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',
    },
  };
}
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:

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');
  });
});

The service needs:

Key implementation details:


Task 4: OAuthController — HTTP Endpoints

Files:

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:


Task 5: OAuthModule + App Registration

Files:

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: UserOAuthConnection.name, schema: UserOAuthConnectionSchema },
    ]),
  ],
  controllers: [OAuthController],
  providers: [OAuthService],
  exports: [OAuthService],
})
export class OAuthModule {}

Task 6: BackendApiClientService — OAuth Proxy Method

Files:

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 [];
  }
}

Task 7: OAuth Tool Plugin for Vitto

Files:

Two tools:

  1. oauth_status — check which services the user has connected
  2. oauth_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
      },
    },
  ];
}

Post-Implementation Notes

What This Plan Delivers

What This Defers

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