-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(auth): add OAuth 2.1 provider for MCP connector support #3274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
40a77c2
feat(auth): add OAuth 2.1 provider for MCP connector support
waleedlatif1 e557b02
fix(auth): rename redirect_u_r_ls column to redirect_urls
waleedlatif1 39566a1
chore(db): regenerate oauth migration with correct column naming
waleedlatif1 e268bfb
fix(auth): reorder CORS headers and handle missing redirectURI
waleedlatif1 eaac9d2
fix(auth): redirect to login without stale callbackUrl on account switch
waleedlatif1 73742e5
chore: run lint
waleedlatif1 59fc084
fix(auth): override credentials header on OAuth CORS entries
waleedlatif1 987e696
fix(auth): preserve OAuth flow when switching accounts on consent page
waleedlatif1 312e545
fix(auth): add session and user-id checks to authorize-params endpoint
waleedlatif1 28e13b7
fix(auth): add expiry check, credentials, MCP CORS, and scope in WWW-…
waleedlatif1 c45ad4b
feat(mcp): add tool annotations for Connectors Directory compliance
waleedlatif1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,275 @@ | ||
| 'use client' | ||
|
|
||
| import { useCallback, useEffect, useState } from 'react' | ||
| import { ArrowLeftRight } from 'lucide-react' | ||
| import Image from 'next/image' | ||
| import { useRouter, useSearchParams } from 'next/navigation' | ||
| import { Button } from '@/components/emcn' | ||
| import { signOut, useSession } from '@/lib/auth/auth-client' | ||
| import { inter } from '@/app/_styles/fonts/inter/inter' | ||
| import { soehne } from '@/app/_styles/fonts/soehne/soehne' | ||
| import { BrandedButton } from '@/app/(auth)/components/branded-button' | ||
|
|
||
| const SCOPE_DESCRIPTIONS: Record<string, string> = { | ||
| openid: 'Verify your identity', | ||
| profile: 'Access your basic profile information', | ||
| email: 'View your email address', | ||
| offline_access: 'Maintain access when you are not actively using the app', | ||
| 'mcp:tools': 'Use Sim workflows and tools on your behalf', | ||
| } as const | ||
|
|
||
| interface ClientInfo { | ||
| clientId: string | ||
| name: string | ||
| icon: string | ||
| } | ||
|
|
||
| export default function OAuthConsentPage() { | ||
| const router = useRouter() | ||
| const searchParams = useSearchParams() | ||
| const { data: session } = useSession() | ||
| const consentCode = searchParams.get('consent_code') | ||
| const clientId = searchParams.get('client_id') | ||
| const scope = searchParams.get('scope') | ||
|
|
||
| const [clientInfo, setClientInfo] = useState<ClientInfo | null>(null) | ||
| const [loading, setLoading] = useState(true) | ||
| const [submitting, setSubmitting] = useState(false) | ||
| const [error, setError] = useState<string | null>(null) | ||
|
|
||
| const scopes = scope?.split(' ').filter(Boolean) ?? [] | ||
|
|
||
| useEffect(() => { | ||
| if (!clientId) { | ||
| setLoading(false) | ||
| setError('The authorization request is missing a required client identifier.') | ||
| return | ||
| } | ||
|
|
||
| fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' }) | ||
| .then(async (res) => { | ||
| if (!res.ok) return | ||
| const data = await res.json() | ||
| setClientInfo(data) | ||
| }) | ||
| .catch(() => {}) | ||
| .finally(() => { | ||
| setLoading(false) | ||
| }) | ||
| }, [clientId]) | ||
|
|
||
| const handleConsent = useCallback( | ||
| async (accept: boolean) => { | ||
| if (!consentCode) { | ||
| setError('The authorization request is missing a required consent code.') | ||
| return | ||
| } | ||
|
|
||
| setSubmitting(true) | ||
| try { | ||
| const res = await fetch('/api/auth/oauth2/consent', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| credentials: 'include', | ||
| body: JSON.stringify({ accept, consent_code: consentCode }), | ||
| }) | ||
|
|
||
| if (!res.ok) { | ||
| const body = await res.json().catch(() => null) | ||
| setError( | ||
| (body as Record<string, string> | null)?.message ?? | ||
| 'The consent request could not be processed. Please try again.' | ||
| ) | ||
| setSubmitting(false) | ||
| return | ||
| } | ||
|
|
||
| const data = (await res.json()) as { redirectURI?: string } | ||
| if (data.redirectURI) { | ||
| window.location.href = data.redirectURI | ||
| } else { | ||
| setError('The server did not return a redirect. Please try again.') | ||
| setSubmitting(false) | ||
| } | ||
| } catch { | ||
| setError('Something went wrong. Please try again.') | ||
| setSubmitting(false) | ||
| } | ||
| }, | ||
| [consentCode] | ||
| ) | ||
|
|
||
| const handleSwitchAccount = useCallback(async () => { | ||
| if (!consentCode) return | ||
|
|
||
| const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, { | ||
| credentials: 'include', | ||
| }) | ||
| if (!res.ok) { | ||
| setError('Unable to switch accounts. Please re-initiate the connection.') | ||
| return | ||
| } | ||
|
|
||
| const params = (await res.json()) as Record<string, string | null> | ||
| const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin) | ||
| for (const [key, value] of Object.entries(params)) { | ||
| if (value) authorizeUrl.searchParams.set(key, value) | ||
| } | ||
|
|
||
| await signOut({ | ||
| fetchOptions: { | ||
| onSuccess: () => { | ||
| window.location.href = authorizeUrl.toString() | ||
| }, | ||
| }, | ||
| }) | ||
| }, [consentCode]) | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className='flex flex-col items-center justify-center'> | ||
| <div className='space-y-1 text-center'> | ||
| <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}> | ||
| Authorize Application | ||
| </h1> | ||
| <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}> | ||
| Loading application details... | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className='flex flex-col items-center justify-center'> | ||
| <div className='space-y-1 text-center'> | ||
| <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}> | ||
| Authorization Error | ||
| </h1> | ||
| <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}> | ||
| {error} | ||
| </p> | ||
| </div> | ||
| <div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}> | ||
| <BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| const clientName = clientInfo?.name ?? clientId | ||
|
|
||
| return ( | ||
| <div className='flex flex-col items-center justify-center'> | ||
| <div className='mb-6 flex items-center gap-4'> | ||
| {clientInfo?.icon ? ( | ||
| <Image | ||
| src={clientInfo.icon} | ||
| alt={clientName ?? 'Application'} | ||
| width={48} | ||
| height={48} | ||
| className='rounded-[10px]' | ||
| unoptimized | ||
| /> | ||
| ) : ( | ||
| <div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'> | ||
| {(clientName ?? '?').charAt(0).toUpperCase()} | ||
| </div> | ||
| )} | ||
| <ArrowLeftRight className='h-5 w-5 text-muted-foreground' /> | ||
| <Image | ||
| src='/new/logo/colorized-bg.svg' | ||
| alt='Sim' | ||
| width={48} | ||
| height={48} | ||
| className='rounded-[10px]' | ||
| /> | ||
| </div> | ||
|
|
||
| <div className='space-y-1 text-center'> | ||
| <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}> | ||
| Authorize Application | ||
| </h1> | ||
| <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}> | ||
| <span className='font-medium text-foreground'>{clientName}</span> is requesting access to | ||
| your account | ||
| </p> | ||
| </div> | ||
|
|
||
| {session?.user && ( | ||
| <div | ||
| className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`} | ||
| > | ||
| {session.user.image ? ( | ||
| <Image | ||
| src={session.user.image} | ||
| alt={session.user.name ?? 'User'} | ||
| width={32} | ||
| height={32} | ||
| className='rounded-full' | ||
| unoptimized | ||
| /> | ||
| ) : ( | ||
| <div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'> | ||
| {(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()} | ||
| </div> | ||
| )} | ||
| <div className='min-w-0'> | ||
| {session.user.name && ( | ||
| <p className='truncate font-medium text-[14px]'>{session.user.name}</p> | ||
| )} | ||
| <p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p> | ||
| </div> | ||
| <button | ||
| type='button' | ||
| onClick={handleSwitchAccount} | ||
| className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline' | ||
| > | ||
| Switch | ||
| </button> | ||
| </div> | ||
| )} | ||
|
|
||
| {scopes.length > 0 && ( | ||
| <div className={`${inter.className} mt-5 w-full max-w-[410px]`}> | ||
| <div className='rounded-lg border p-4'> | ||
| <p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p> | ||
| <ul className='space-y-2'> | ||
| {scopes.map((s) => ( | ||
| <li | ||
| key={s} | ||
| className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground' | ||
| > | ||
| <span className='mt-0.5 text-green-500'>✓</span> | ||
| <span>{SCOPE_DESCRIPTIONS[s] ?? s}</span> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}> | ||
| <Button | ||
| variant='outline' | ||
| size='md' | ||
| className='px-6 py-2' | ||
| disabled={submitting} | ||
| onClick={() => handleConsent(false)} | ||
| > | ||
| Deny | ||
| </Button> | ||
| <BrandedButton | ||
| fullWidth | ||
| showArrow={false} | ||
| loading={submitting} | ||
| loadingText='Authorizing' | ||
| onClick={() => handleConsent(true)} | ||
| > | ||
| Allow | ||
| </BrandedButton> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { db } from '@sim/db' | ||
| import { verification } from '@sim/db/schema' | ||
| import { and, eq, gt } from 'drizzle-orm' | ||
| import type { NextRequest } from 'next/server' | ||
| import { NextResponse } from 'next/server' | ||
| import { getSession } from '@/lib/auth' | ||
|
|
||
| /** | ||
| * Returns the original OAuth authorize parameters stored in the verification record | ||
| * for a given consent code. Used by the consent page to reconstruct the authorize URL | ||
| * when switching accounts. | ||
| */ | ||
| export async function GET(request: NextRequest) { | ||
| const session = await getSession() | ||
| if (!session?.user) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) | ||
| } | ||
|
|
||
| const consentCode = request.nextUrl.searchParams.get('consent_code') | ||
| if (!consentCode) { | ||
| return NextResponse.json({ error: 'consent_code is required' }, { status: 400 }) | ||
| } | ||
|
|
||
| const [record] = await db | ||
| .select({ value: verification.value }) | ||
| .from(verification) | ||
| .where(and(eq(verification.identifier, consentCode), gt(verification.expiresAt, new Date()))) | ||
| .limit(1) | ||
|
|
||
| if (!record) { | ||
| return NextResponse.json({ error: 'Invalid or expired consent code' }, { status: 404 }) | ||
| } | ||
|
|
||
| const data = JSON.parse(record.value) as { | ||
| clientId: string | ||
| redirectURI: string | ||
| scope: string[] | ||
| userId: string | ||
| codeChallenge: string | ||
| codeChallengeMethod: string | ||
| state: string | null | ||
| nonce: string | null | ||
| } | ||
|
|
||
| if (data.userId !== session.user.id) { | ||
| return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) | ||
| } | ||
|
|
||
| return NextResponse.json({ | ||
| client_id: data.clientId, | ||
| redirect_uri: data.redirectURI, | ||
| scope: data.scope.join(' '), | ||
| code_challenge: data.codeChallenge, | ||
| code_challenge_method: data.codeChallengeMethod, | ||
| state: data.state, | ||
| nonce: data.nonce, | ||
| response_type: 'code', | ||
| }) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.