# OIDC Authentication Plugin

A flexible OpenID Connect (OIDC) authentication plugin for Flink that supports generic Identity Providers (IdPs) with MongoDB session storage, JWT token generation, and configurable token handling.

## Features

- OpenID Connect Authorization Code flow with any OIDC-compliant IdP
- Automatic JWT token generation via JWT Auth Plugin integration
- MongoDB session storage with automatic TTL cleanup
- Support for multiple OIDC providers per application
- OIDC Discovery support (automatic endpoint configuration)
- Manual endpoint configuration for custom IdPs
- PKCE (Proof Key for Code Exchange) for enhanced security
- CSRF protection with cryptographically secure state parameters
- Nonce validation for ID token replay protection
- Encrypted token storage (AES-256-GCM)
- JIT (Just-In-Time) user provisioning
- Built-in HTTP endpoints for OIDC flow
- TypeScript support with full type safety
- Configurable response formats (JSON, URL fragment)
- Dynamic provider loading from database

## Installation

```bash
npm install @flink-app/oidc-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 OIDC plugin uses the JWT Auth Plugin to generate authentication tokens after successful OIDC authentication.

### 2. OIDC Provider Credentials

You need OIDC application credentials from your Identity Provider:

#### Generic OIDC Provider Setup

1. Register your application with your IdP
2. Configure the redirect URI: `https://yourdomain.com/oidc/{provider}/callback`
3. Obtain Client ID and Client Secret
4. Note the Issuer URL (e.g., `https://idp.example.com`)
5. Get the Discovery URL (usually `{issuer}/.well-known/openid-configuration`)

#### Common OIDC Providers

- **Azure AD / Entra ID**: `https://login.microsoftonline.com/{tenant}/v2.0`
- **Okta**: `https://{domain}.okta.com`
- **Auth0**: `https://{domain}.auth0.com`
- **Keycloak**: `https://{domain}/realms/{realm}`
- **Google**: `https://accounts.google.com` (use oauth-plugin for Google)

### 3. MongoDB Connection

The plugin requires MongoDB to store OIDC sessions during the authentication flow.

## Quick Start

```typescript
import { FlinkApp } from "@flink-app/flink";
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
import { oidcPlugin } from "@flink-app/oidc-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: [
        oidcPlugin({
            providers: {
                // Provider name used in URLs: /oidc/acme/initiate
                acme: {
                    issuer: process.env.OIDC_ISSUER!,
                    clientId: process.env.OIDC_CLIENT_ID!,
                    clientSecret: process.env.OIDC_CLIENT_SECRET!,
                    callbackUrl: "https://myapp.com/oidc/acme/callback",

                    // Option 1: Use OIDC Discovery (recommended)
                    discoveryUrl: `${process.env.OIDC_ISSUER}/.well-known/openid-configuration`,

                    // Option 2: Manual endpoint configuration (if discovery not available)
                    // authorizationEndpoint: "https://idp.acme.com/authorize",
                    // tokenEndpoint: "https://idp.acme.com/token",
                    // userinfoEndpoint: "https://idp.acme.com/userinfo",
                    // jwksUri: "https://idp.acme.com/.well-known/jwks.json",

                    scope: ["openid", "email", "profile"],
                },
            },

            // Callback after successful OIDC authentication (JIT provisioning)
            onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
                // Find user by OIDC subject + issuer (unique IdP identifier)
                let user = await ctx.repos.userRepo.getOne({
                    "oidcConnections.subject": claims.sub,
                    "oidcConnections.issuer": claims.iss,
                });

                if (!user) {
                    // JIT provisioning - create new user
                    user = await ctx.repos.userRepo.create({
                        email: claims.email,
                        name: claims.name,
                        emailVerified: claims.email_verified || false,
                        oidcConnections: [
                            {
                                issuer: claims.iss,
                                subject: claims.sub,
                                provider: "acme",
                            },
                        ],
                        createdAt: new Date(),
                    });
                }

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

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

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

await app.start();
```

## Configuration

### OidcPluginOptions

| Option                      | Type       | Required | Default            | Description                                            |
| --------------------------- | ---------- | -------- | ------------------ | ------------------------------------------------------ |
| `providers`                 | `object`   | Yes      | -                  | OIDC provider configurations (at least one required)   |
| `storeTokens`               | `boolean`  | No       | `false`            | Store encrypted OIDC tokens for future API access      |
| `onAuthSuccess`             | `Function` | Yes      | -                  | Callback after successful authentication (JIT)         |
| `onAuthError`               | `Function` | No       | -                  | Callback on OIDC errors                                |
| `providerLoader`            | `Function` | No       | -                  | Dynamic provider loading from database                 |
| `sessionTTL`                | `number`   | No       | `600`              | Session TTL in seconds (default: 10 minutes)           |
| `sessionsCollectionName`    | `string`   | No       | `"oidc_sessions"`  | MongoDB collection for sessions                        |
| `connectionsCollectionName` | `string`   | No       | `"oidc_connections"` | MongoDB collection for connections                  |
| `encryptionKey`             | `string`   | No       | (derived)          | Encryption key for tokens (32+ chars recommended)      |
| `registerRoutes`            | `boolean`  | No       | `true`             | Auto-register OIDC routes                              |

### Provider Configuration

#### OIDC Discovery (Recommended)

```typescript
{
  issuer: "https://idp.acme.com",
  clientId: "your-client-id",
  clientSecret: "your-client-secret",
  callbackUrl: "https://myapp.com/oidc/acme/callback",
  discoveryUrl: "https://idp.acme.com/.well-known/openid-configuration",
  scope: ["openid", "email", "profile"]
}
```

#### Manual Configuration

```typescript
{
  issuer: "https://idp.acme.com",
  clientId: "your-client-id",
  clientSecret: "your-client-secret",
  callbackUrl: "https://myapp.com/oidc/acme/callback",
  authorizationEndpoint: "https://idp.acme.com/authorize",
  tokenEndpoint: "https://idp.acme.com/token",
  userinfoEndpoint: "https://idp.acme.com/userinfo",
  jwksUri: "https://idp.acme.com/.well-known/jwks.json",
  scope: ["openid", "email", "profile"]
}
```

### Callback Functions

#### onAuthSuccess

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

```typescript
onAuthSuccess: async (params: {
  profile: OidcProfile;      // Normalized user profile
  claims: Record<string, any>; // Raw OIDC claims
  provider: string;           // Provider name
  tokens?: OidcTokenSet;      // Only if storeTokens: true
}, ctx: Context) => Promise<{
  user: any;
  token: string;  // JWT token from ctx.plugins.jwtAuth.createToken()
  redirectUrl?: string;
}>
```

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

#### onAuthError

Called when OIDC authentication fails.

```typescript
onAuthError: async (params: {
  error: OidcError;
  provider: string;
}) => Promise<{
  redirectUrl?: string;
}>
```

## OIDC Flow

### Complete Authentication Flow

```
1. User visits: GET /login
   → Shows "Login via Portal" button

2. User clicks button
   → Redirects to: GET /oidc/acme/initiate

3. Plugin generates state, code_verifier, nonce, stores session
   → Redirects to IdP: https://idp.acme.com/authorize?
                        client_id=...&
                        redirect_uri=https://myapp.com/oidc/acme/callback&
                        scope=openid+email+profile&
                        state=...&
                        code_challenge=...&
                        code_challenge_method=S256&
                        nonce=...&
                        response_type=code

4. User logs in at IdP portal
   → IdP validates credentials

5. IdP redirects back: GET /oidc/acme/callback?code=...&state=...

6. Plugin validates state (CSRF protection)
   → Exchanges code for tokens using code_verifier (PKCE)
   → Validates ID token signature (JWT)
   → Validates nonce in ID token (replay protection)
   → Extracts claims from ID token
   → Optionally calls UserInfo endpoint

7. Plugin calls onAuthSuccess with profile
   → App checks if user exists by subject+issuer
   → If not, JIT create user (provisions account)
   → App generates JWT token via ctx.plugins.jwtAuth.createToken()

8. Plugin returns JWT to client
   → Redirects to: https://myapp.com/dashboard#token=eyJ...

9. Frontend extracts token from URL fragment
   → Stores JWT in localStorage/sessionStorage
   → Uses JWT for subsequent API calls
```

### Initiate OIDC Flow

```
GET /oidc/:provider/initiate?redirectUri={optional_redirect}
```

**Example:**

```
GET /oidc/acme/initiate
GET /oidc/acme/initiate?redirectUri=https://myapp.com/welcome
```

**Response:**

- 302 redirect to IdP authorization URL

### OIDC Callback

```
GET /oidc/:provider/callback?code={auth_code}&state={state}&response_type={json}
```

**Query Parameters:**

- `code` - Authorization code from IdP
- `state` - CSRF protection token
- `response_type` - Optional: `json` for JSON response, omit for redirect

**Response Formats:**

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

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

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

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

### Extracting JWT Token in Frontend

**IMPORTANT:** The plugin returns the JWT token as a **URL fragment** (`#token=...`), NOT as a query parameter (`?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");

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

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

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

## Context API

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

### getConnection

Get stored OIDC connection for a user and provider.

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

// Returns OidcConnection or null
interface OidcConnection {
    _id: string;
    userId: string;
    provider: string;
    subject: string; // OIDC sub claim
    issuer: string; // OIDC iss claim
    email?: string;
    accessToken?: string; // Decrypted (if storeTokens enabled)
    idToken?: string; // Decrypted
    refreshToken?: string; // Decrypted
    scope?: string;
    expiresAt?: Date;
    createdAt: Date;
    updatedAt: Date;
}
```

### getConnections

Get all OIDC connections for a user.

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

### deleteConnection

Delete/unlink an OIDC connection.

```typescript
await ctx.plugins.oidc.deleteConnection(userId, "acme");
```

## Token Storage

### Auth-Only Mode (Default)

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

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

**Use when:**

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

### Token Storage Mode

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

```typescript
oidcPlugin({
    providers: { acme: {...} },
    storeTokens: true,  // Store encrypted OIDC tokens
    onAuthSuccess: async ({ profile, claims, tokens }, ctx) => {
        // tokens.accessToken, tokens.idToken, tokens.refreshToken are available
        // Tokens are automatically encrypted and stored
    }
})
```

**Use when:**

- You need to call IdP APIs on behalf of users
- You want to access user's resources at the IdP
- You need long-term API access via refresh tokens

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

## JIT Provisioning Patterns

### Basic JIT

```typescript
onAuthSuccess: async ({ profile, claims }, ctx) => {
    let user = await ctx.repos.userRepo.getOne({
        "oidcConnections.subject": claims.sub,
        "oidcConnections.issuer": claims.iss,
    });

    if (!user) {
        user = await ctx.repos.userRepo.create({
            email: claims.email,
            name: claims.name,
            oidcConnections: [
                {
                    issuer: claims.iss,
                    subject: claims.sub,
                    provider: "acme",
                },
            ],
        });
    }

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

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

### JIT with Email Matching

```typescript
onAuthSuccess: async ({ profile, claims }, ctx) => {
    // First try to find by OIDC connection
    let user = await ctx.repos.userRepo.getOne({
        "oidcConnections.subject": claims.sub,
        "oidcConnections.issuer": claims.iss,
    });

    // If not found, try to find by email
    if (!user) {
        user = await ctx.repos.userRepo.getOne({ email: claims.email });

        if (user) {
            // Link OIDC connection to existing user
            user.oidcConnections = user.oidcConnections || [];
            user.oidcConnections.push({
                issuer: claims.iss,
                subject: claims.sub,
                provider: "acme",
            });
            await ctx.repos.userRepo.updateOne(user._id, {
                oidcConnections: user.oidcConnections,
            });
        } else {
            // Create new user
            user = await ctx.repos.userRepo.create({
                email: claims.email,
                name: claims.name,
                oidcConnections: [
                    {
                        issuer: claims.iss,
                        subject: claims.sub,
                        provider: "acme",
                    },
                ],
            });
        }
    }

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

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

### JIT with Role Mapping

```typescript
onAuthSuccess: async ({ profile, claims }, ctx) => {
    const groups = claims.groups || []; // Custom claim from IdP
    const roles = mapGroupsToRoles(groups); // Your mapping logic

    let user = await ctx.repos.userRepo.getOne({
        "oidcConnections.subject": claims.sub,
        "oidcConnections.issuer": claims.iss,
    });

    if (!user) {
        user = await ctx.repos.userRepo.create({
            email: claims.email,
            name: claims.name,
            roles,
            oidcConnections: [
                {
                    issuer: claims.iss,
                    subject: claims.sub,
                    provider: "acme",
                },
            ],
        });
    } else {
        // Update roles on each login
        await ctx.repos.userRepo.updateOne(user._id, { roles });
    }

    const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, roles);

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

function mapGroupsToRoles(groups: string[]): string[] {
    const roleMap: Record<string, string> = {
        "admins": "admin",
        "developers": "developer",
        "users": "user",
    };

    return groups.map((group) => roleMap[group] || "user").filter(Boolean);
}
```

## Multi-Tenant / Dynamic Providers

Load OIDC provider configurations from database at runtime:

```typescript
oidcPlugin({
    providers: {},  // Empty static config

    // Dynamic loader
    providerLoader: async (providerName) => {
        const config = await ctx.repos.oidcProviderRepo.getByName(providerName);

        if (!config || !config.enabled) {
            return null;
        }

        return {
            issuer: config.issuer,
            clientId: config.clientId,
            clientSecret: decryptSecret(config.clientSecret),
            callbackUrl: config.callbackUrl,
            discoveryUrl: config.discoveryUrl,
            scope: config.scope || ["openid", "email", "profile"]
        };
    },

    onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
        // ... JIT provisioning
    }
})
```

### Database Schema for Dynamic Providers

```typescript
interface OidcProviderConfigDB {
    _id: string;
    name: string; // Provider name (used in URLs)
    organizationId?: string; // For multi-tenant
    enabled: boolean;
    issuer: string;
    clientId: string;
    clientSecret: string; // Store encrypted!
    callbackUrl: string;
    discoveryUrl?: string;
    authorizationEndpoint?: string;
    tokenEndpoint?: string;
    userinfoEndpoint?: string;
    jwksUri?: string;
    scope: string[];
    createdAt: Date;
    updatedAt: Date;
}
```

## 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

### PKCE (Proof Key for Code Exchange)

PKCE prevents authorization code interception attacks:

1. Generate `code_verifier` (random 43-128 char string)
2. Calculate `code_challenge` = BASE64URL(SHA256(code_verifier))
3. Send `code_challenge` in authorization request
4. Send `code_verifier` in token exchange
5. IdP validates: SHA256(code_verifier) === code_challenge

Even if attacker steals authorization code, they can't exchange it without the `code_verifier`.

### ID Token Validation

The plugin validates ID tokens automatically:

- JWT signature verification using IdP's public keys (JWKS)
- Issuer (`iss`) claim validation
- Audience (`aud`) claim validation (must match client ID)
- Expiration (`exp`) claim validation
- Nonce validation for replay protection

### Token Encryption

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

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

### HTTPS Requirement

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

### Secrets Management

Never commit secrets to version control:

```bash
# .env
OIDC_ISSUER=https://idp.acme.com
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
OIDC_ENCRYPTION_KEY=your_encryption_key_32_chars_min
JWT_SECRET=your_jwt_secret
```

## Client Integration Examples

### React Web App

```typescript
import React from "react";

function LoginPage() {
    const handleOidcLogin = () => {
        // Redirect to OIDC initiation
        window.location.href = "/oidc/acme/initiate?redirectUri=https://myapp.com/dashboard";
    };

    React.useEffect(() => {
        // Extract token from URL fragment
        const hash = window.location.hash.slice(1);
        const params = new URLSearchParams(hash);
        const token = params.get("token");

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

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

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

    return (
        <div>
            <h1>Login</h1>
            <button onClick={handleOidcLogin}>Login via Portal</button>
        </div>
    );
}
```

### React Native App

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

async function loginWithOidc() {
    const result = await openAuthSessionAsync("https://api.myapp.com/oidc/acme/initiate", "myapp://oidc/callback");

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

        // Extract token from URL fragment
        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
        }
    }
}
```

## Troubleshooting

### OIDC Discovery Failed

**Issue:** `discovery_failed` error

**Solution:**

- Verify discovery URL is correct
- Check IdP is accessible from your server
- Try manual endpoint configuration instead

### Invalid State Parameter

**Issue:** `invalid_state` error

**Solution:**

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

### Token Exchange Failed

**Issue:** `token_exchange_failed` error

**Solution:**

- Verify client ID and secret are correct
- Check callback URL matches exactly (including trailing slashes)
- Ensure code hasn't expired (typically 10 minutes)
- Check PKCE is supported by IdP

### ID Token Validation Failed

**Issue:** `id_token_validation_failed` error

**Solution:**

- Verify issuer URL matches ID token `iss` claim
- Check client ID matches ID token `aud` claim
- Ensure ID token hasn't expired
- Verify nonce matches

### 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

## TypeScript Types

```typescript
import {
    OidcPluginOptions,
    OidcProfile,
    OidcTokenSet,
    OidcConnection,
    OidcError,
    OidcPluginContext,
} from "@flink-app/oidc-plugin";
```

## Production Checklist

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

## Examples

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

- `basic-oidc.ts` - Basic OIDC authentication with JIT provisioning
- `multi-provider.ts` - Multiple OIDC provider support
- `token-storage.ts` - Storing OIDC tokens for API access
- `dynamic-providers.ts` - Loading providers from database
- `role-mapping.ts` - Mapping IdP groups to app roles

## License

MIT
