Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,82 @@ LOOPS_EMAIL_VERIFICATION_TEMPLATE_ID=
# Seed User ID
# This is the user ID that will be used to seed the database with sample data
SEED_USER_ID=test-user-123

# External Authentication Configuration Examples

# ============================================================================
# EXTERNAL AUTH MODE
# ============================================================================

# Options: "better-auth", "proxy", "disabled"
EXTERNAL_AUTH_MODE=disabled

# Provider selection (better-auth mode only)
# Options: "google", "github", "entra", "custom"
#EXTERNAL_AUTH_PROVIDER=google

# --- Google OAuth ---
# Create credentials: https://console.cloud.google.com/
# Redirect URI: https://your-domain.com/api/auth/callback/google
#OAUTH_GOOGLE_CLIENT_ID=123456789-abcdef.apps.googleusercontent.com
#OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here

# --- GitHub OAuth ---
# Create OAuth App: https://github.com/settings/developers
# Callback URL: https://your-domain.com/api/auth/callback/github
#OAUTH_GITHUB_CLIENT_ID=Iv1.1234567890abcdef
#OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret

# --- Microsoft Entra ID (Azure AD) ---
# Register app: https://portal.azure.com/
# Redirect URI: https://your-domain.com/api/auth/callback/microsoft-entra-id
#OAUTH_ENTRA_CLIENT_ID=12345678-1234-1234-1234-123456789abc
#OAUTH_ENTRA_CLIENT_SECRET=your-entra-secret
#OAUTH_ENTRA_TENANT_ID=your-tenant-id

# --- Custom OIDC Provider ---
# For any OAuth 2.0 / OpenID Connect provider
#OAUTH_CUSTOM_AUTHORIZATION_URL=https://provider.com/oauth/authorize
#OAUTH_CUSTOM_TOKEN_URL=https://provider.com/oauth/token
#OAUTH_CUSTOM_USERINFO_URL=https://provider.com/oauth/userinfo
#OAUTH_CUSTOM_CLIENT_ID=your-client-id
#OAUTH_CUSTOM_CLIENT_SECRET=your-client-secret

# ============================================================================
# PROXY MODE
# ============================================================================

# Header names (defaults shown, customize if your proxy uses different headers)
#PROXY_USER_HEADER=x-auth-request-user
#PROXY_EMAIL_HEADER=x-auth-request-email
#PROXY_JWT_HEADER=x-auth-request-jwt

# JWT Verification

# Option 1: JWKS URL (recommended for production)
# The proxy should expose a JWKS endpoint with public keys
#PROXY_JWT_JWKS_URL=https://your-proxy.com/.well-known/jwks.json

# Option 2: Static Public Key (PEM format)
# Use this if proxy signs JWTs with a static key pair
#PROXY_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
#MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
#-----END PUBLIC KEY-----"

# JWT Claims Validation (recommended)
#PROXY_JWT_ISSUER=https://your-proxy.com
#PROXY_JWT_AUDIENCE=timetracker-mcp

# ============================================================================
# EXTERNAL AUTH POLICIES
# ============================================================================

# Auto-create local user on first external login
# true: Create new user automatically (recommended)
# false: Only allow login for existing users
EXTERNAL_CREATE_LOCAL_USER=true

# Account linking policy
# auto: Automatically link external accounts by matching email (recommended)
# require-manual-link: Users must manually link accounts via UI
EXTERNAL_LINKING_POLICY=auto
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ A **conversational-first time tracking platform** built on the **Model Context P
- **Multi-User with Shared Resources** — individual time tracking with shared clients and projects for seamless team collaboration.
- **Data Integrity Protection** — clients and projects with tracked time cannot be deactivated, preserving historical data.
- **Patched Authentication** — uses [Better Auth](https://github.com/better-auth/better-auth) with our patch ([PR #3091](https://github.com/better-auth/better-auth/pull/3091)) adding enhanced PKCE support described in `docs/better-auth-patch.md`.
- **External OAuth Support** — integrate Google, GitHub, Microsoft Entra ID, or custom OAuth/OIDC providers; supports OAuth proxy mode for enterprise deployments. See [External Auth Guide](docs/EXTERNAL_AUTH.md).
- **Open User Registration** — by default, anyone can create an account through the signup page at `/app/signup`.
- **Optional Email Verification** — secure user registration with email verification via [Loops.js](https://loops.so) (optional feature).
- **Dark/Light Theme** — implemented with `next-themes` & CSS variables.
Expand Down Expand Up @@ -202,6 +203,27 @@ By default, email verification is disabled (`ENABLE_EMAIL_VERIFICATION=false` in

For more details on authentication configuration options, refer to the [Better Auth documentation](https://www.better-auth.com/docs/reference/options).

### External OAuth Authentication

TimeTracker MCP supports external OAuth/OIDC authentication providers through two modes:

1. **Better Auth Integration** — OAuth providers (Google, GitHub, Microsoft Entra ID, custom OIDC) integrated directly
2. **OAuth Proxy Mode** — Use an external OAuth proxy with signed JWT headers for enterprise deployments
3. **Disabled** — Default mode with email/password only

For complete configuration instructions, see **[External Authentication Guide](docs/EXTERNAL_AUTH.md)**.

Quick configuration:
```bash
# Enable external auth
EXTERNAL_AUTH_MODE=better-auth # or "proxy" or "disabled"

# For Better Auth mode - configure OAuth provider
EXTERNAL_AUTH_PROVIDER=google
OAUTH_GOOGLE_CLIENT_ID=your-client-id
OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret
```

### Optional Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
Expand Down
45 changes: 45 additions & 0 deletions app/api/auth/external/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* External OAuth Callback Handler
*
* Handles OAuth callbacks from external providers when using EXTERNAL_AUTH_MODE=better-auth.
* This creates a local user and establishes a Better Auth session after successful OAuth.
*/

import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { findOrCreateExternalUser } from '@/lib/external-auth';
import { env } from '@/lib/env';

export async function GET(req: NextRequest) {
try {
const mode = env.EXTERNAL_AUTH_MODE;

// Only handle callbacks in better-auth mode
if (mode !== 'better-auth') {
return NextResponse.json(
{ error: 'External auth not configured in better-auth mode' },
{ status: 400 }
);
}

// Better Auth handles the OAuth callback automatically
// The account will be created via the Better Auth OAuth flow
// We just need to ensure the session is established
const session = await auth.api.getSession({ headers: req.headers });

if (!session || !session.user) {
return NextResponse.json(
{ error: 'OAuth authentication failed' },
{ status: 401 }
);
}
const redirectUrl = new URL('/dashboard', req.url);
return NextResponse.redirect(redirectUrl);

} catch (error) {
console.error('External auth callback error:', error);
const redirectUrl = new URL('/login', req.url);
redirectUrl.searchParams.set('error', 'oauth_callback_failed');
return NextResponse.redirect(redirectUrl);
}
}
13 changes: 13 additions & 0 deletions app/api/auth/external/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { env } from '@/lib/env';

/**
* GET /api/auth/external/config
* Returns the external auth configuration for the client
*/
export async function GET() {
return NextResponse.json({
mode: env.EXTERNAL_AUTH_MODE,
provider: env.EXTERNAL_AUTH_PROVIDER,
});
}
138 changes: 138 additions & 0 deletions app/api/auth/external/link/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Account Linking API
*
* Allows users to link/unlink external OAuth accounts to their existing account.
*/

import { NextRequest, NextResponse } from 'next/server';
import { getUserIdOrThrow } from '@/lib/authUtils';
import { db } from '@/drizzle/connection';
import { user } from '@/drizzle/better-auth-schema';
import { eq } from 'drizzle-orm';

/**
* GET /api/auth/external/link
* Get linked external accounts for the current user
*/
export async function GET(req: NextRequest) {
try {
const userId = await getUserIdOrThrow(req);

const userRecord = await db.query.user.findFirst({
where: eq(user.id, userId),
columns: {
id: true,
email: true,
externalProvider: true,
externalId: true,
externalEmail: true,
}
});

if (!userRecord) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

return NextResponse.json({
linkedAccount: userRecord.externalProvider ? {
provider: userRecord.externalProvider,
externalId: userRecord.externalId,
externalEmail: userRecord.externalEmail,
} : null
});

} catch (error) {
if (error instanceof Response) return error;
console.error('Error fetching linked accounts:', error);
return NextResponse.json(
{ error: 'Failed to fetch linked accounts' },
{ status: 500 }
);
}
}

/**
* POST /api/auth/external/link
* Link an external account to the current user
*
* Body: { provider: string, externalId: string, externalEmail: string }
*/
export async function POST(req: NextRequest) {
try {
const userId = await getUserIdOrThrow(req);
const body = await req.json();

const { provider, externalId, externalEmail } = body;

if (!provider || !externalId || !externalEmail) {
return NextResponse.json(
{ error: 'Missing required fields: provider, externalId, externalEmail' },
{ status: 400 }
);
}

const existingLink = await db.query.user.findFirst({
where: eq(user.externalId, externalId)
});
if (existingLink && existingLink.id !== userId) {
return NextResponse.json(
{ error: 'This external account is already linked to another user' },
{ status: 409 }
);
}

await db.update(user)
.set({
externalProvider: provider,
externalId: externalId,
externalEmail: externalEmail,
updatedAt: new Date()
})
.where(eq(user.id, userId));

return NextResponse.json({
success: true,
message: 'External account linked successfully'
});

} catch (error) {
if (error instanceof Response) return error;
console.error('Error linking external account:', error);
return NextResponse.json(
{ error: 'Failed to link external account' },
{ status: 500 }
);
}
}

/**
* DELETE /api/auth/external/link
* Unlink external account from the current user
*/
export async function DELETE(req: NextRequest) {
try {
const userId = await getUserIdOrThrow(req);

await db.update(user)
.set({
externalProvider: null,
externalId: null,
externalEmail: null,
updatedAt: new Date()
})
.where(eq(user.id, userId));

return NextResponse.json({
success: true,
message: 'External account unlinked successfully'
});

} catch (error) {
if (error instanceof Response) return error;
console.error('Error unlinking external account:', error);
return NextResponse.json(
{ error: 'Failed to unlink external account' },
{ status: 500 }
);
}
}
Loading