@flink-app/oauth-plugin
v0.12.1-alpha.45
Published
Flink plugin for OAuth 2.0 authentication with GitHub and Google providers
Readme
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
npm install @flink-app/oauth-plugin @flink-app/jwt-auth-pluginPrerequisites
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
- Go to GitHub Developer Settings
- Create a new OAuth App
- Set Authorization callback URL to
https://yourdomain.com/oauth/github/callback - Save Client ID and Client Secret
Google OAuth App
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google+ API
- Go to Credentials > Create Credentials > OAuth 2.0 Client ID
- Set Authorized redirect URI to
https://yourdomain.com/oauth/google/callback - Save Client ID and Client Secret
3. MongoDB Connection
The plugin requires MongoDB to store OAuth sessions.
Quick Start
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
{
github: {
clientId: string;
clientSecret: string;
callbackUrl: string;
scope?: string[]; // Default: ["user:email"]
}
}Google Provider
{
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.
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:
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):
interface OAuthTokens {
accessToken: string;
refreshToken?: string;
expiresIn?: number;
scope?: string;
}onAuthError
Called when OAuth authentication fails.
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 expiredaccess_denied- User denied OAuth authorizationinvalid_grant- Authorization code expired or invalidnetwork_error- Provider API unreachablejwt_generation_failed- Failed to generate JWT token
OAuth Flow
Complete Authentication Flow
- User clicks "Login with GitHub" or "Login with Google"
- Client redirects to
/oauth/:provider/initiate - Plugin generates secure state parameter and stores session
- Plugin redirects user to OAuth provider (GitHub/Google)
- User authorizes app on OAuth provider
- OAuth provider redirects to
/oauth/:provider/callbackwith authorization code - Plugin validates state parameter (CSRF protection)
- Plugin exchanges authorization code for OAuth access token
- Plugin fetches user profile from provider
- Plugin calls
onAuthSuccesscallback with profile and context - App creates/links user account
- App generates JWT token via
ctx.plugins.jwtAuth.createToken() - Plugin returns JWT token to client via URL fragment
- 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:
// ✅ 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:
// ✅ 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 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/welcomeResponse:
- 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 providerstate- CSRF protection tokenresponse_type- Optional response format:jsonfor JSON response, otherwise redirect
Response Formats:
- 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:
// ❌ 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=...
};- 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{
"user": {
"_id": "...",
"email": "[email protected]",
"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.
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.
const connections = await ctx.plugins.oauth.getConnections(userId);
// Returns OAuthConnection[]deleteConnection
Delete/unlink an OAuth connection.
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.
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.
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 authenticationSecurity
CSRF Protection
The plugin uses cryptographically secure state parameters to prevent CSRF attacks:
- Generate 32-byte random state using
crypto.randomBytes() - Store state in MongoDB session with 10-minute expiration
- Validate state on callback using constant-time comparison
- 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:
# .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_secretJWT 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:
// 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
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
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:
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
- Provider abstraction - Easy to add new OAuth providers
- Flexible token storage - Choose whether to store OAuth tokens
- Better error handling - Dedicated
onAuthErrorcallback - Multiple provider support - Built-in linking of GitHub + Google
- Cleaner separation - Plugin handles OAuth, app handles user logic
Migration Example
BankID Plugin:
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:
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.jwtAuthis available inonAuthSuccess - Check JWT secret is set in environment variables
User Denied Access
Issue: User cancels OAuth authorization
Solution:
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: truein plugin configuration - Verify
onAuthSuccesscompletes successfully - Check MongoDB connection is active
TypeScript Types
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 JWTmulti-provider.ts- Multiple provider linkingtoken-storage.ts- Storing OAuth tokens for API accessapi-client-auth.ts- API client integration withresponse_type=json
License
MIT
