# OAuth Plugin

A flexible OAuth 2.0 authentication plugin for Flink that supports multiple providers (GitHub, Google) with MongoDB session storage, JWT token generation, and configurable token handling.

## Features

-   OAuth 2.0 Authorization Code flow for GitHub and Google
-   Automatic JWT token generation via JWT Auth Plugin integration
-   MongoDB session storage with automatic TTL cleanup
-   Support for linking multiple OAuth providers to a single user account
-   Flexible token storage (store OAuth tokens for API access or auth-only mode)
-   CSRF protection with cryptographically secure state parameters
-   Encrypted OAuth token storage (AES-256-GCM)
-   Built-in HTTP endpoints for OAuth flow
-   TypeScript support with full type safety
-   Configurable response formats (JSON, URL fragment, query parameter)

## Installation

```bash
npm install @flink-app/oauth-plugin @flink-app/jwt-auth-plugin
```

## Prerequisites

### 1. JWT Auth Plugin Dependency

This plugin requires `@flink-app/jwt-auth-plugin` to be installed and configured. The OAuth plugin uses the JWT Auth Plugin to generate authentication tokens after successful OAuth authentication.

### 2. OAuth Provider Credentials

You need OAuth application credentials from your desired providers:

#### GitHub OAuth App

1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set Authorization callback URL to `https://yourdomain.com/oauth/github/callback`
4. Save Client ID and Client Secret

#### Google OAuth App

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable Google+ API
4. Go to Credentials > Create Credentials > OAuth 2.0 Client ID
5. Set Authorized redirect URI to `https://yourdomain.com/oauth/google/callback`
6. Save Client ID and Client Secret

### 3. MongoDB Connection

The plugin requires MongoDB to store OAuth sessions.

## Quick Start

```typescript
import { FlinkApp } from "@flink-app/flink";
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
import { oauthPlugin } from "@flink-app/oauth-plugin";
import { Context } from "./Context";

const app = new FlinkApp<Context>({
    name: "My App",

    // JWT Auth Plugin MUST be configured first
    auth: jwtAuthPlugin({
        secret: process.env.JWT_SECRET!,
        getUser: async (tokenData) => {
            return await app.ctx.repos.userRepo.getById(tokenData.userId);
        },
        rolePermissions: {
            user: ["read:own", "write:own"],
            admin: ["read:all", "write:all"],
        },
    }),

    db: {
        uri: process.env.MONGODB_URI!,
    },

    plugins: [
        oauthPlugin({
            providers: {
                github: {
                    clientId: process.env.GITHUB_CLIENT_ID!,
                    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
                    callbackUrl: "https://myapp.com/oauth/github/callback",
                },
                google: {
                    clientId: process.env.GOOGLE_CLIENT_ID!,
                    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
                    callbackUrl: "https://myapp.com/oauth/google/callback",
                },
            },

            // Callback after successful OAuth authentication
            onAuthSuccess: async ({ profile, provider }, ctx) => {
                // Find or create user
                let user = await ctx.repos.userRepo.getOne({ email: profile.email });

                if (!user) {
                    user = await ctx.repos.userRepo.create({
                        email: profile.email,
                        name: profile.name,
                        avatarUrl: profile.avatarUrl,
                        oauthProviders: [{ provider, providerId: profile.id }],
                    });
                } else {
                    // Link provider to existing user
                    await ctx.repos.userRepo.updateOne(user._id, {
                        oauthProviders: [...user.oauthProviders, { provider, providerId: profile.id }],
                    });
                }

                // Generate JWT token using JWT Auth Plugin
                const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id, email: user.email }, ["user"]);

                return {
                    user,
                    token,
                    redirectUrl: "https://myapp.com/dashboard", // Plugin will add: #token=...
                };
            },

            // Optional: Handle OAuth errors
            onAuthError: async ({ error, provider }) => {
                console.error(`OAuth error for ${provider}:`, error);
                return {
                    redirectUrl: `https://myapp.com/login?error=${error.code}`,
                };
            },
        }),
    ],
});

await app.start();
```

## Configuration

### OAuthPluginOptions

| Option                      | Type       | Required | Default               | Description                                    |
| --------------------------- | ---------- | -------- | --------------------- | ---------------------------------------------- |
| `providers`                 | `object`   | Yes      | -                     | OAuth provider configurations (GitHub, Google) |
| `storeTokens`               | `boolean`  | No       | `false`               | Store OAuth tokens for future API access       |
| `onAuthSuccess`             | `Function` | Yes      | -                     | Callback after successful authentication       |
| `onAuthError`               | `Function` | No       | -                     | Callback on OAuth errors                       |
| `sessionTTL`                | `number`   | No       | `600`                 | Session TTL in seconds (default: 10 minutes)   |
| `sessionsCollectionName`    | `string`   | No       | `"oauth_sessions"`    | MongoDB collection for sessions                |
| `connectionsCollectionName` | `string`   | No       | `"oauth_connections"` | MongoDB collection for connections             |

### Provider Configuration

#### GitHub Provider

```typescript
{
  github: {
    clientId: string;
    clientSecret: string;
    callbackUrl: string;
    scope?: string[];  // Default: ["user:email"]
  }
}
```

#### Google Provider

```typescript
{
  google: {
    clientId: string;
    clientSecret: string;
    callbackUrl: string;
    scope?: string[];  // Default: ["openid", "email", "profile"]
  }
}
```

### Callback Functions

#### onAuthSuccess

Called when OAuth authentication succeeds. Must generate and return a JWT token.

```typescript
onAuthSuccess: async (
    params: {
        profile: OAuthProfile;
        provider: "github" | "google";
        tokens?: OAuthTokens; // Only if storeTokens: true
    },
    ctx: Context
) =>
    Promise<{
        user: any;
        token: string; // JWT token from ctx.plugins.jwtAuth.createToken()
        redirectUrl?: string; // Plugin appends #token=... to this URL
    }>;
```

**Important:** The `redirectUrl` should NOT include the token. The plugin automatically appends `#token=...` to the URL you return.

**OAuth Profile Structure:**

```typescript
interface OAuthProfile {
    id: string; // Provider user ID
    email: string; // User email
    name?: string; // Full name
    avatarUrl?: string; // Profile picture URL
    raw: any; // Raw provider response
}
```

**OAuth Tokens Structure (if storeTokens: true):**

```typescript
interface OAuthTokens {
    accessToken: string;
    refreshToken?: string;
    expiresIn?: number;
    scope?: string;
}
```

#### onAuthError

Called when OAuth authentication fails.

```typescript
onAuthError: async (params: { error: OAuthError; provider: "github" | "google" }) =>
    Promise<{
        redirectUrl?: string;
    }>;

interface OAuthError {
    code: string; // Error code (e.g., "access_denied")
    message: string; // User-friendly error message
    details?: any; // Additional error details
}
```

**Common Error Codes:**

-   `invalid_state` - State parameter mismatch or expired
-   `access_denied` - User denied OAuth authorization
-   `invalid_grant` - Authorization code expired or invalid
-   `network_error` - Provider API unreachable
-   `jwt_generation_failed` - Failed to generate JWT token

## OAuth Flow

### Complete Authentication Flow

1. User clicks "Login with GitHub" or "Login with Google"
2. Client redirects to `/oauth/:provider/initiate`
3. Plugin generates secure state parameter and stores session
4. Plugin redirects user to OAuth provider (GitHub/Google)
5. User authorizes app on OAuth provider
6. OAuth provider redirects to `/oauth/:provider/callback` with authorization code
7. Plugin validates state parameter (CSRF protection)
8. Plugin exchanges authorization code for OAuth access token
9. Plugin fetches user profile from provider
10. Plugin calls `onAuthSuccess` callback with profile and context
11. App creates/links user account
12. **App generates JWT token via `ctx.plugins.jwtAuth.createToken()`**
13. **Plugin returns JWT token to client via URL fragment**
14. Client stores JWT token and uses it for authenticated requests

### How to Extract JWT Token in Frontend

**IMPORTANT:** The plugin returns the JWT token as a **URL fragment** (`#token=...`), NOT as a query parameter (`?token=...`).

URL fragments are more secure because they are:
- NOT sent to the server in HTTP requests
- NOT logged in server access logs
- Only accessible to client-side JavaScript

**Correct way to extract the token:**

```javascript
// ✅ CORRECT - Read from URL fragment (hash)
const hash = window.location.hash.slice(1); // Remove leading #
const params = new URLSearchParams(hash);
const token = params.get("token");

// ❌ WRONG - Reading from query parameters won't work
const params = new URLSearchParams(window.location.search);
const token = params.get("token"); // This returns null!
```

**In your `onAuthSuccess` callback:**

```typescript
// ✅ CORRECT - Just return the redirectUrl, plugin adds #token=...
return {
    user,
    token,
    redirectUrl: "https://myapp.com/dashboard",
};
// Result: https://myapp.com/dashboard#token=eyJ...

// ❌ WRONG - Don't manually add the token
return {
    user,
    token,
    redirectUrl: `https://myapp.com/dashboard?token=${token}`,
};
// Plugin will add token again: ...?token=eyJ...#token=eyJ...
```

See [Client Integration Examples](#client-integration-examples) for complete frontend code.

### Initiate OAuth Flow

```
GET /oauth/:provider/initiate?redirect_uri={optional_redirect}
```

**Example:**

```
GET /oauth/github/initiate
GET /oauth/google/initiate?redirect_uri=https://myapp.com/welcome
```

**Response:**

-   302 redirect to OAuth provider authorization URL

### OAuth Callback

```
GET /oauth/:provider/callback?code={auth_code}&state={state}&response_type={json|fragment|query}
```

**Query Parameters:**

-   `code` - Authorization code from provider
-   `state` - CSRF protection token
-   `response_type` - Optional response format: `json` for JSON response, otherwise redirect

**Response Formats:**

1. **URL Fragment Redirect** (default):

The plugin redirects to your `redirectUrl` with the JWT token appended as a **URL fragment** for enhanced security:

```
https://myapp.com/dashboard#token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

**Why fragments?** URL fragments (`#token=...`) are NOT sent to the server in HTTP requests, making them more secure than query parameters. They're only accessible to client-side JavaScript.

**Important:** Do NOT manually add the token to your `redirectUrl`. The plugin automatically appends it:

```typescript
// ❌ WRONG - Don't do this
return {
    user,
    token,
    redirectUrl: `${frontendUrl}/auth/callback?token=${token}`, // Plugin will add token again!
};

// ✅ CORRECT - Let plugin append the token
return {
    user,
    token,
    redirectUrl: `${frontendUrl}/auth/callback`, // Plugin adds: #token=...
};
```

2. **JSON Response** (when `response_type=json`):

Use `response_type=json` for API clients that need direct JSON response instead of redirect:

```
GET /oauth/github/callback?code=xxx&state=yyy&response_type=json
```

```json
{
    "user": {
        "_id": "...",
        "email": "user@example.com",
        "name": "John Doe"
    },
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

## Context API

The plugin exposes methods via `ctx.plugins.oauth`:

### getConnection

Get stored OAuth connection for a user and provider.

```typescript
const connection = await ctx.plugins.oauth.getConnection(userId, "github");

// Returns OAuthConnection or null
interface OAuthConnection {
    _id: string;
    userId: string;
    provider: "github" | "google";
    providerId: string;
    accessToken: string; // Encrypted
    refreshToken?: string; // Encrypted
    scope: string;
    expiresAt?: Date;
    createdAt: Date;
    updatedAt: Date;
}
```

### getConnections

Get all OAuth connections for a user.

```typescript
const connections = await ctx.plugins.oauth.getConnections(userId);
// Returns OAuthConnection[]
```

### deleteConnection

Delete/unlink an OAuth connection.

```typescript
await ctx.plugins.oauth.deleteConnection(userId, "github");
```

## Token Storage

### Auth-Only Mode (Default)

By default, `storeTokens: false`, meaning OAuth tokens are NOT stored. OAuth is used only for authentication.

```typescript
oauthPlugin({
  providers: { github: {...}, google: {...} },
  storeTokens: false,  // OAuth tokens discarded after auth
  onAuthSuccess: async ({ profile }, ctx) => {
    // Create user and generate JWT token
    // OAuth tokens are NOT available here
  }
})
```

**Use when:**

-   You only need OAuth for user authentication
-   You don't need to call provider APIs on behalf of users
-   You want to minimize stored credentials

### Token Storage Mode

Set `storeTokens: true` to store encrypted OAuth tokens for future API access.

```typescript
oauthPlugin({
  providers: { github: {...}, google: {...} },
  storeTokens: true,  // Store encrypted OAuth tokens
  onAuthSuccess: async ({ profile, tokens }, ctx) => {
    // tokens.accessToken and tokens.refreshToken are available
    // Tokens are automatically encrypted and stored
  }
})
```

**Use when:**

-   You need to call GitHub/Google APIs on behalf of users
-   You want to access user's GitHub repos or Google Drive
-   You need long-term API access

**Note:** OAuth tokens are encrypted using AES-256-GCM before storage.

## JWT vs OAuth Tokens

It's important to understand the difference between OAuth tokens and JWT tokens:

### OAuth Tokens

-   **Purpose:** Access provider APIs (GitHub, Google) on behalf of the user
-   **Issued by:** OAuth provider (GitHub, Google)
-   **Used for:** Calling GitHub API, Google API, etc.
-   **Storage:** Optional (only if `storeTokens: true`)
-   **Lifetime:** Varies by provider (hours to months)

### JWT Tokens

-   **Purpose:** Authenticate requests to YOUR app
-   **Issued by:** Your app (via JWT Auth Plugin)
-   **Used for:** Accessing protected endpoints in your app
-   **Storage:** Client-side (localStorage, sessionStorage)
-   **Lifetime:** Configured in JWT Auth Plugin

**Example Flow:**

```
User OAuth Login (GitHub)
  -> OAuth access token (to call GitHub API)
  -> Your app generates JWT token
  -> Client uses JWT token for app authentication
```

## Security

### CSRF Protection

The plugin uses cryptographically secure state parameters to prevent CSRF attacks:

1. Generate 32-byte random state using `crypto.randomBytes()`
2. Store state in MongoDB session with 10-minute expiration
3. Validate state on callback using constant-time comparison
4. Clear session after successful validation

### Token Encryption

When `storeTokens: true`, OAuth tokens are encrypted before storage:

-   **Algorithm:** AES-256-GCM
-   **Encryption key:** Derived from client secret
-   **Storage:** Encrypted tokens in MongoDB
-   **Decryption:** Automatic when retrieved via context methods

### HTTPS Requirement

**IMPORTANT:** OAuth callback URLs MUST use HTTPS in production. OAuth providers reject HTTP callback URLs for security reasons.

### Secrets Management

Never commit secrets to version control:

```bash
# .env
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
JWT_SECRET=your_jwt_secret
```

### JWT Token Security

-   Store JWT tokens in secure storage (httpOnly cookies or secure localStorage)
-   Never expose JWT tokens in URLs for long-term storage
-   Use short token expiration times
-   Implement token refresh mechanism
-   Validate tokens on every request

## API Client Integration

For API clients (mobile apps, SPAs), use `response_type=json`:

```typescript
// Mobile app OAuth flow
const initiateUrl = "https://api.myapp.com/oauth/github/initiate";

// Open browser for OAuth
openBrowser(initiateUrl);

// After OAuth, catch callback
const callbackUrl = "https://api.myapp.com/oauth/github/callback?code=xxx&state=yyy&response_type=json";

// Fetch JSON response
const response = await fetch(callbackUrl);
const { user, token } = await response.json();

// Store JWT token in secure storage
await secureStorage.setItem("jwt_token", token);

// Use JWT token for authenticated requests
const headers = {
    Authorization: `Bearer ${token}`,
};
```

## Client Integration Examples

### React Web App

```typescript
import React from "react";

function LoginPage() {
    const handleGitHubLogin = () => {
        // Redirect to OAuth initiation
        window.location.href = "/oauth/github/initiate?redirect_uri=https://myapp.com/dashboard";
    };

    const handleGoogleLogin = () => {
        window.location.href = "/oauth/google/initiate?redirect_uri=https://myapp.com/dashboard";
    };

    React.useEffect(() => {
        // IMPORTANT: Token is in URL FRAGMENT (#token=...), not query parameter (?token=...)
        const hash = window.location.hash.slice(1); // Remove the leading #
        const params = new URLSearchParams(hash);
        const token = params.get("token");

        if (token) {
            // Store JWT token
            localStorage.setItem("jwt_token", token);

            // Clean URL (remove fragment)
            window.history.replaceState({}, document.title, "/dashboard");

            // Redirect to dashboard
            window.location.href = "/dashboard";
        }
    }, []);

    return (
        <div>
            <h1>Login</h1>
            <button onClick={handleGitHubLogin}>Login with GitHub</button>
            <button onClick={handleGoogleLogin}>Login with Google</button>
        </div>
    );
}
```

### React Native App

```typescript
import { openAuthSessionAsync } from "expo-auth-session";

async function loginWithGitHub() {
    const result = await openAuthSessionAsync("https://api.myapp.com/oauth/github/initiate", "myapp://oauth/callback");

    if (result.type === "success") {
        const url = result.url;

        // IMPORTANT: Token is in URL FRAGMENT (#token=...), not query parameter (?token=...)
        const urlObj = new URL(url);
        const token = new URLSearchParams(urlObj.hash.slice(1)).get("token");

        if (token) {
            await AsyncStorage.setItem("jwt_token", token);
            // Navigate to home screen
        }
    }
}
```

## Multiple Provider Linking

Allow users to link multiple OAuth providers to their account:

```typescript
onAuthSuccess: async ({ profile, provider }, ctx) => {
    // Find user by email (regardless of provider)
    let user = await ctx.repos.userRepo.getOne({ email: profile.email });

    if (user) {
        // User exists - link new provider
        const existingProviders = user.oauthProviders || [];
        const isAlreadyLinked = existingProviders.some((p) => p.provider === provider && p.providerId === profile.id);

        if (!isAlreadyLinked) {
            await ctx.repos.userRepo.updateOne(user._id, {
                oauthProviders: [...existingProviders, { provider, providerId: profile.id }],
            });
        }
    } else {
        // New user - create account
        user = await ctx.repos.userRepo.create({
            email: profile.email,
            name: profile.name,
            avatarUrl: profile.avatarUrl,
            oauthProviders: [{ provider, providerId: profile.id }],
        });
    }

    // Generate JWT token
    const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, ["user"]);

    return { user, token, redirectUrl: "/dashboard" };
};
```

## Migration from BankID Plugin

If you're migrating from the BankID plugin, the OAuth plugin follows similar patterns:

### Similarities

-   Callback-based architecture with `onAuthSuccess`
-   JWT token generation via JWT Auth Plugin
-   MongoDB session storage with TTL
-   Context-based dependency injection

### Improvements

1. **Provider abstraction** - Easy to add new OAuth providers
2. **Flexible token storage** - Choose whether to store OAuth tokens
3. **Better error handling** - Dedicated `onAuthError` callback
4. **Multiple provider support** - Built-in linking of GitHub + Google
5. **Cleaner separation** - Plugin handles OAuth, app handles user logic

### Migration Example

**BankID Plugin:**

```typescript
bankIdPlugin({
    onAuthSuccess: async (userData, ip, payload) => {
        const user = await findOrCreateUser(userData);
        const token = await ctx.auth.createToken({ userId: user._id }, ["user"]);
        return { user, token };
    },
});
```

**OAuth Plugin:**

```typescript
oauthPlugin({
    onAuthSuccess: async ({ profile, provider }, ctx) => {
        const user = await findOrCreateUser(profile, provider);
        const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, ["user"]);
        return { user, token, redirectUrl: "/dashboard" };
    },
});
```

## Troubleshooting

### OAuth Error: Invalid Redirect URI

**Issue:** `redirect_uri_mismatch` error from OAuth provider

**Solution:**

-   Verify callback URL in provider settings matches exactly
-   Ensure callback URL uses HTTPS in production
-   Check for trailing slashes (they matter!)

### State Parameter Mismatch

**Issue:** `invalid_state` error

**Solution:**

-   Ensure cookies are enabled (sessions use MongoDB, but state validation may use cookies)
-   Check session TTL hasn't expired (default: 10 minutes)
-   Verify clock synchronization between servers

### JWT Token Not Generated

**Issue:** `jwt_generation_failed` error

**Solution:**

-   Ensure JWT Auth Plugin is configured
-   Verify `ctx.plugins.jwtAuth` is available in `onAuthSuccess`
-   Check JWT secret is set in environment variables

### User Denied Access

**Issue:** User cancels OAuth authorization

**Solution:**

```typescript
onAuthError: async ({ error, provider }) => {
    if (error.code === "access_denied") {
        return {
            redirectUrl: "/login?message=You must authorize the app to continue",
        };
    }
    return { redirectUrl: "/login?error=oauth_failed" };
};
```

### Tokens Not Stored

**Issue:** `getConnection()` returns null

**Solution:**

-   Set `storeTokens: true` in plugin configuration
-   Verify `onAuthSuccess` completes successfully
-   Check MongoDB connection is active

## TypeScript Types

```typescript
import { OAuthPluginOptions, OAuthProfile, OAuthTokens, OAuthConnection, OAuthError, OAuthPluginContext } from "@flink-app/oauth-plugin";

// OAuth profile from provider
interface OAuthProfile {
    id: string;
    email: string;
    name?: string;
    avatarUrl?: string;
    raw: any;
}

// OAuth tokens (if storeTokens: true)
interface OAuthTokens {
    accessToken: string;
    refreshToken?: string;
    expiresIn?: number;
    scope?: string;
}

// Stored connection
interface OAuthConnection {
    _id: string;
    userId: string;
    provider: "github" | "google";
    providerId: string;
    accessToken: string;
    refreshToken?: string;
    scope: string;
    expiresAt?: Date;
    createdAt: Date;
    updatedAt: Date;
}
```

## Production Checklist

-   [ ] Configure HTTPS for all OAuth callback URLs
-   [ ] Set OAuth credentials in secure environment variables
-   [ ] Configure JWT Auth Plugin with secure secret
-   [ ] Set appropriate JWT token expiration
-   [ ] Implement rate limiting on OAuth endpoints
-   [ ] Set up monitoring and error alerting
-   [ ] Test OAuth flow for all providers
-   [ ] Implement proper error handling in callbacks
-   [ ] Configure CORS for OAuth endpoints
-   [ ] Set up session cleanup and monitoring
-   [ ] Document OAuth provider setup for team
-   [ ] Test token refresh mechanism (if using stored tokens)

## Examples

See the `examples/` directory for complete working examples:

-   `basic-auth.ts` - Basic OAuth authentication with JWT
-   `multi-provider.ts` - Multiple provider linking
-   `token-storage.ts` - Storing OAuth tokens for API access
-   `api-client-auth.ts` - API client integration with `response_type=json`

## License

MIT
