auth.do.dev Implementation Guide
Prerequisites
Convex Auth Deployment
The dedicated auth Convex deployment has been created and configured:
# Auth Convex Deployment (Unified Authentication Service)
CONVEX_DEPLOYMENT_AUTH="dev:dependable-pika-747"
NEXT_PUBLIC_CONVEX_URL_AUTH="https://dependable-pika-747.convex.cloud"These environment variables are already configured in the root .env.local file.
Required Environment Variables
Ensure the following are set in your root .env.local:
# Convex Auth Deployment
CONVEX_DEPLOYMENT_AUTH="dev:dependable-pika-747"
NEXT_PUBLIC_CONVEX_URL_AUTH="https://dependable-pika-747.convex.cloud"
# Email Service (already configured)
<<<<<<< HEAD
RESEND_API_KEY="re_VDEseoNb_Cf2QYfWZqTNwS23B5G61njqh"
=======
RESEND_API_KEY="re_xxxxxxxxxxxx" # Replace with your actual Resend API key
>>>>>>> fcd62685a9d930d1de5a3007c43332340dff891c
AUTH_RESEND_FROM="hello@do.dev"
# OAuth (to be configured)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"Project Setup
1. Create the Auth Service App
# In apps/webs/
pnpm create next-app auth --typescript --tailwind --app
cd auth
# Add dependencies
pnpm add convex @convex-dev/auth lucide-react
pnpm add -D @types/node2. Convex Schema Design
// apps/webs/auth/convex/schema.ts
import { defineSchema, defineTable } from "convex/server"
import { v } from "convex/values"
export default defineSchema({
// Core user table
users: defineTable({
// Identity
email: v.string(),
emailVerified: v.boolean(),
name: v.optional(v.string()),
image: v.optional(v.string()),
// Auth providers
providers: v.array(v.object({
provider: v.string(), // "email", "google", "github"
providerId: v.string(),
connectedAt: v.number(),
})),
// Multi-app access
apps: v.array(v.object({
appId: v.string(), // "dodev", "promptnow", etc.
roles: v.array(v.string()), // ["user", "admin"]
firstAccess: v.number(),
lastAccess: v.number(),
})),
// Metadata
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_email", ["email"]),
// Session management
sessions: defineTable({
userId: v.id("users"),
token: v.string(),
refreshToken: v.string(),
// App context
appId: v.string(),
returnUrl: v.optional(v.string()),
// Expiration
expiresAt: v.number(),
refreshExpiresAt: v.number(),
// Device info
userAgent: v.optional(v.string()),
ipAddress: v.optional(v.string()),
}).index("by_token", ["token"])
.index("by_refresh", ["refreshToken"])
.index("by_user_app", ["userId", "appId"]),
// OAuth state management
oauthStates: defineTable({
state: v.string(),
provider: v.string(),
appId: v.string(),
returnUrl: v.string(),
createdAt: v.number(),
expiresAt: v.number(),
}).index("by_state", ["state"]),
// App registry
apps: defineTable({
appId: v.string(),
name: v.string(),
domain: v.string(),
allowedRedirects: v.array(v.string()),
// App-specific settings
settings: v.object({
allowEmailAuth: v.boolean(),
allowGoogleAuth: v.boolean(),
allowGithubAuth: v.boolean(),
requireEmailVerification: v.boolean(),
sessionDuration: v.number(), // in seconds
}),
// Branding
branding: v.object({
primaryColor: v.string(),
logo: v.optional(v.string()),
}),
active: v.boolean(),
}).index("by_app_id", ["appId"]),
})3. Core Auth Functions
// apps/webs/auth/convex/auth.ts
import { mutation, query } from "./_generated/server"
import { v } from "convex/values"
import { nanoid } from "nanoid"
// Generate secure tokens
const generateToken = () => nanoid(32)
const generateRefreshToken = () => nanoid(64)
export const createSession = mutation({
args: {
userId: v.id("users"),
appId: v.string(),
returnUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const token = generateToken()
const refreshToken = generateRefreshToken()
// 15 minute access token, 30 day refresh
const expiresAt = Date.now() + (15 * 60 * 1000)
const refreshExpiresAt = Date.now() + (30 * 24 * 60 * 60 * 1000)
await ctx.db.insert("sessions", {
userId: args.userId,
token,
refreshToken,
appId: args.appId,
returnUrl: args.returnUrl,
expiresAt,
refreshExpiresAt,
})
return { token, refreshToken, expiresAt }
},
})
export const validateSession = query({
args: { token: v.string() },
handler: async (ctx, args) => {
const session = await ctx.db
.query("sessions")
.withIndex("by_token", q => q.eq("token", args.token))
.first()
if (!session || session.expiresAt < Date.now()) {
return null
}
const user = await ctx.db.get(session.userId)
return { session, user }
},
})
export const initiateOAuth = mutation({
args: {
provider: v.string(),
appId: v.string(),
returnUrl: v.string(),
},
handler: async (ctx, args) => {
const state = generateToken()
await ctx.db.insert("oauthStates", {
state,
provider: args.provider,
appId: args.appId,
returnUrl: args.returnUrl,
createdAt: Date.now(),
expiresAt: Date.now() + (10 * 60 * 1000), // 10 minutes
})
return { state }
},
})4. Auth UI Components
// apps/webs/auth/components/auth-form.tsx
"use client"
import { useState } from "react"
import { useSearchParams } from "next/navigation"
export function UnifiedAuthForm() {
const searchParams = useSearchParams()
const appId = searchParams.get("app") || "unknown"
const returnUrl = searchParams.get("return") || "/"
const [email, setEmail] = useState("")
const [flow, setFlow] = useState<"signin" | "verify">("signin")
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">
Sign in to {getAppName(appId)}
</h1>
{flow === "signin" ? (
<EmailSignIn
onSubmit={async (email) => {
// Send OTP
setFlow("verify")
}}
/>
) : (
<VerifyCode
email={email}
onVerify={async (code) => {
// Verify and redirect
window.location.href = returnUrl
}}
/>
)}
<div className="mt-6">
<OAuthButtons appId={appId} returnUrl={returnUrl} />
</div>
</div>
)
}
function OAuthButtons({ appId, returnUrl }: { appId: string; returnUrl: string }) {
const handleOAuth = (provider: string) => {
window.location.href = `/api/auth/oauth/${provider}?app=${appId}&return=${encodeURIComponent(returnUrl)}`
}
return (
<div className="space-y-3">
<button
onClick={() => handleOAuth("google")}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
<GoogleIcon />
Continue with Google
</button>
<button
onClick={() => handleOAuth("github")}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
<GithubIcon />
Continue with GitHub
</button>
</div>
)
}5. API Routes
// apps/webs/auth/app/api/auth/validate/route.ts
import { NextRequest, NextResponse } from "next/server"
import { api } from "@/convex/_generated/api"
import { fetchQuery } from "convex/nextjs"
export async function POST(request: NextRequest) {
const { token } = await request.json()
const result = await fetchQuery(api.auth.validateSession, { token })
if (!result) {
return NextResponse.json({ valid: false }, { status: 401 })
}
return NextResponse.json({
valid: true,
user: {
id: result.user._id,
email: result.user.email,
name: result.user.name,
},
session: {
appId: result.session.appId,
expiresAt: result.session.expiresAt,
},
})
}6. Client SDK
// packages/auth-client/src/index.ts
export class AuthClient {
constructor(private authUrl: string = "https://auth.do.dev") {}
async validateSession(token: string): Promise<SessionInfo | null> {
const res = await fetch(`${this.authUrl}/api/auth/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
})
if (!res.ok) return null
return res.json()
}
redirectToLogin(appId: string, returnUrl?: string): void {
const url = new URL(`${this.authUrl}/login`)
url.searchParams.set("app", appId)
if (returnUrl) {
url.searchParams.set("return", returnUrl)
}
window.location.href = url.toString()
}
async refreshToken(refreshToken: string): Promise<TokenPair | null> {
const res = await fetch(`${this.authUrl}/api/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
})
if (!res.ok) return null
return res.json()
}
}7. App Integration
// In any app (dodev, promptnow, etc.)
// hooks/use-unified-auth.ts
import { useEffect, useState } from "react"
import { AuthClient } from "@workspace/auth-client"
import Cookies from "js-cookie"
const authClient = new AuthClient()
export function useUnifiedAuth() {
const [session, setSession] = useState<SessionInfo | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = Cookies.get("auth_token")
if (!token) {
setLoading(false)
return
}
authClient.validateSession(token)
.then(setSession)
.finally(() => setLoading(false))
}, [])
const signIn = () => {
authClient.redirectToLogin(
process.env.NEXT_PUBLIC_APP_ID!,
window.location.href
)
}
const signOut = () => {
Cookies.remove("auth_token")
setSession(null)
}
return { session, loading, signIn, signOut }
}Security Best Practices
-
Token Storage
- Use httpOnly cookies for tokens
- Implement CSRF protection
- Short-lived access tokens
-
OAuth Security
- Validate state parameter
- Verify redirect URLs against whitelist
- Use PKCE for OAuth flows
-
Rate Limiting
- Implement per-IP rate limiting
- Exponential backoff for failed attempts
- Account lockout after repeated failures
-
Audit Logging
- Log all auth events
- Track IP addresses
- Monitor for suspicious patterns
Deployment Checklist
Completed
- Create dedicated auth Convex deployment (dependable-pika-747)
- Configure environment variables in root .env.local
Pending
- Create auth.do.dev Next.js app structure
- Set up auth.do.dev domain and DNS
- Configure OAuth apps (Google, GitHub) with dynamic redirect URLs
- Deploy Convex schema and functions to dependable-pika-747
- Set up monitoring and logging
- Configure rate limiting
- Test OAuth with multiple localhost ports
- Test with production domains
- Document all API endpoints
- Create app migration guides
Next Steps
- Create the auth app in
apps/webs/auth/ - Configure it to use the dependable-pika-747 deployment
- Implement the schema and auth functions
- Set up OAuth with dynamic redirect handling
- Create the auth-client SDK package
- Test with local-test app first
- Migrate other apps one by one