Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions apps/chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@klicker-uzh/prisma": "workspace:*",
"@klicker-uzh/shared-components": "workspace:*",
"@klicker-uzh/types": "workspace:*",
"@klicker-uzh/util": "workspace:*",
"@langfuse/otel": "4.6.1",
"@langfuse/tracing": "4.6.1",
"@modelcontextprotocol/sdk": "1.17.5",
Expand Down
24 changes: 23 additions & 1 deletion apps/chat/src/app/api/chatbots/[chatbotId]/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ export async function POST(
if ('response' in authResult) {
return authResult.response
}
const { participantId, chatbot: authChatbot } = authResult
const { participantId, authMode, chatbot: authChatbot } = authResult

// check disclaimer acceptance
try {
Expand Down Expand Up @@ -1012,6 +1012,28 @@ export async function POST(
}
}

// Phase A: anonymous (LTI guest) users are locked to fallback models.
// This is the FINAL model override — runs after all account-mode model
// resolution branches so it cannot be silently overwritten by the
// `!chatbot.modelSelection` branch (the bug from the original branch).
// Phase B replaces this with reasoning-effort tier gating.
if (authMode === 'anonymous' && !selectedModelConfig.fallback) {
// Pick a fallback directly. `getAutomaticModelId` returns the *primary*
// when credits>0 even when called with `chatbot.allowedModelIds`, which
// would re-trip this branch and 503 anonymous users that have credits.
const guestFallback = modelRegistry.find(
(m) => m.fallback && (allowedIds === null || allowedIds.has(m.id))
)
if (!guestFallback) {
return NextResponse.json(
{ error: 'No fallback model available for guest access' },
{ status: 503 }
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
selectedModel = guestFallback.id
selectedModelConfig = guestFallback
}

const allowedReasoningEfforts = getAllowedReasoningEffortsForModel(
selectedModelConfig,
chatbot.allowedReasoningEffortsByModel
Expand Down
69 changes: 42 additions & 27 deletions apps/chat/src/app/api/chatbots/[chatbotId]/credits/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function GET(
if ('response' in authResult) {
return authResult.response
}
const { participantId } = authResult
const { participantId, authMode } = authResult

const chatbotResult = await getChatbotOr404(chatbotId, {
courseId: true,
Expand All @@ -35,36 +35,51 @@ export async function GET(
chatbotId
)

const availableModels = getModelsForChatbot(
chatbotResult.chatbot,
credits
).map(
({
id,
name,
description,
fallback,
supportsReasoning,
supportsImageAttachments,
supportedReasoningEfforts,
}) => ({
id,
name,
description,
fallback,
supportsReasoning,
supportsImageAttachments,
allowedReasoningEfforts: supportedReasoningEfforts,
})
)
let availableModels = getModelsForChatbot(chatbotResult.chatbot, credits)

// Phase A: anonymous (LTI guest) restricted to fallback models only.
// Phase B replaces this with reasoning-effort tier gating so guests can
// use the flagship model at free effort levels.
if (authMode === 'anonymous') {
availableModels = availableModels.filter((m) => m.fallback)
}

const allowedIdsForAuto =
authMode === 'anonymous'
? availableModels.map((m) => m.id)
: chatbotResult.chatbot.allowedModelIds
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// When anonymous and no fallback is available for this chatbot, the
// computed `automaticModelId` would otherwise come from the global
// registry and contradict the empty `availableModels` list.
const automaticModelId =
authMode === 'anonymous' && availableModels.length === 0
? null
: getAutomaticModelId(credits, allowedIdsForAuto)

return NextResponse.json({
...credits,
availableModels,
automaticModelId: getAutomaticModelId(
credits,
chatbotResult.chatbot.allowedModelIds
availableModels: availableModels.map(
({
id,
name,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
description,
fallback,
supportsReasoning,
supportsImageAttachments,
supportedReasoningEfforts,
}) => ({
id,
name,
description,
fallback,
supportsReasoning,
supportsImageAttachments,
allowedReasoningEfforts: supportedReasoningEfforts,
})
),
automaticModelId,
authMode,
})
} catch (error) {
console.error('Failed to fetch credits:', error)
Expand Down
194 changes: 194 additions & 0 deletions apps/chat/src/app/auth/lti/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {
resolveLtiAuthDecision,
signChatGuestToken,
verifyLtiToken,
} from '@/src/lib/server/ltiGuest'
import { prisma } from '@klicker-uzh/prisma'
import { jwtVerify } from 'jose'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const LOG_PREFIX = '[chat:auth/lti]'

const querySchema = z.object({
jwt: z.string().min(1),
courseId: z.string().uuid(),
chatbotId: z.string().uuid(),
})

async function getParticipantTokenSub(
req: NextRequest
): Promise<string | null> {
const token = req.cookies.get('participant_token')?.value
if (!token) return null
const appSecret = process.env.APP_SECRET
if (!appSecret) return null
try {
const result = await jwtVerify(token, new TextEncoder().encode(appSecret))
return typeof result.payload.sub === 'string' &&
result.payload.sub.length > 0
? result.payload.sub
: null
} catch {
return null
}
}

function noLoginRedirect(req: NextRequest, chatbotId: string | null) {
const noLoginUrl = req.nextUrl.clone()
noLoginUrl.pathname = '/noLogin'
noLoginUrl.search = ''
noLoginUrl.searchParams.set('lti', '1')
if (chatbotId) {
noLoginUrl.searchParams.set('redirectTo', `/${chatbotId}`)
}
return NextResponse.redirect(noLoginUrl)
}

export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl

const queryResult = querySchema.safeParse({
jwt: searchParams.get('jwt'),
courseId: searchParams.get('courseId'),
chatbotId: searchParams.get('chatbotId'),
})

if (!queryResult.success) {
console.error(
LOG_PREFIX,
'Invalid query params:',
queryResult.error.flatten()
)
return NextResponse.json(
{
error: 'Missing or invalid query parameters (jwt, courseId, chatbotId)',
},
{ status: 400 }
)
}

const { jwt, courseId, chatbotId } = queryResult.data

let ltiPayload
try {
ltiPayload = await verifyLtiToken(jwt)
} catch (error) {
console.error(LOG_PREFIX, 'LTI JWT verification failed:', error)
return noLoginRedirect(req, chatbotId)
}

const [course, chatbot] = await Promise.all([
prisma.course.findUnique({ where: { id: courseId }, select: { id: true } }),
prisma.chatbot.findUnique({
where: { id: chatbotId },
select: { id: true, courseId: true },
}),
])

if (!course) {
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
}
if (!chatbot) {
return NextResponse.json({ error: 'Chatbot not found' }, { status: 404 })
}
if (chatbot.courseId !== courseId) {
console.error(LOG_PREFIX, 'Cross-course access blocked', {
chatbotCourseId: chatbot.courseId,
requestedCourseId: courseId,
chatbotId,
})
return NextResponse.json(
{ error: 'Chatbot not found in this course' },
{ status: 403 }
)
}

const participantTokenSub = await getParticipantTokenSub(req)

let decision
try {
decision = await resolveLtiAuthDecision({
ltiSub: ltiPayload.sub,
ltiScope: ltiPayload.scope,
courseId,
participantTokenSub,
})
} catch (error) {
console.error(LOG_PREFIX, 'resolveLtiAuthDecision failed:', error)
return NextResponse.json(
{ error: 'Failed to resolve auth decision' },
{ status: 500 }
)
}

console.info(LOG_PREFIX, 'auth resolved', {
mode: decision.mode,
chatbotId,
courseId,
})

// Probe whether third-party cookies survived the LMS iframe context.
// `apps/lti` sets `lti-token` with `secure; sameSite=none; domain=COOKIE_DOMAIN`;
// browsers blocking 3p cookies (Safari ITP, Brave, Firefox total cookie protection
// pre-141, Chrome with Tracking Protection) strip it before this request lands.
// Mirrors the PWA pattern in `getParticipantToken.ts`.
const cookiesAvailable = !!req.cookies.get('lti-token')?.value

const chatbotUrl = req.nextUrl.clone()
chatbotUrl.pathname = `/${chatbotId}`
chatbotUrl.search = ''

const isProduction =
process.env.NODE_ENV === 'production' &&
process.env.COOKIE_DOMAIN !== '127.0.0.1'

if (decision.mode === 'account') {
// Account branch: clear any stale `chat_participant_token` so the
// guest-first middleware order (verify chat-guest before participant) does
// not keep forcing `authMode='anonymous'` after this redirect.
const accountResponse = NextResponse.redirect(chatbotUrl)
accountResponse.cookies.delete({
name: 'chat_participant_token',
path: '/',
})
return accountResponse
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Guest path. Issue chat_participant_token; never override participant_token.
let chatGuestToken
try {
chatGuestToken = await signChatGuestToken(decision.participantId)
} catch (error) {
console.error(LOG_PREFIX, 'Failed to sign chat guest token:', error)
return NextResponse.json(
{ error: 'Failed to create guest session' },
{ status: 500 }
)
}

// sessionStorage fallback for browsers where CHIPS is not yet supported
// (pre-Safari 26.2, Firefox <141). Hand the token off via `?_t=` query so
// the client bootstrap (`useChatGuestTokenBootstrap`) can stuff it into
// sessionStorage and strip the URL parameter via `router.replace`.
if (!cookiesAvailable) {
chatbotUrl.searchParams.set('_t', chatGuestToken)
}

const response = NextResponse.redirect(chatbotUrl)

// Host-only cookie: no `domain` set → cookie never leaves the chat subdomain.
// Backend GraphQL on api.<domain> never sees this token even if leaked.
// `Partitioned` (CHIPS) lets modern browsers keep the cookie in third-party
// iframe contexts (Chrome 114+, Edge 114+, Firefox 141+, Safari 26.2+).
response.cookies.set('chat_participant_token', chatGuestToken, {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'none' : 'lax',
partitioned: isProduction,
path: '/',
maxAge: 60 * 60 * 24 * 14,
})

return response
}
31 changes: 26 additions & 5 deletions apps/chat/src/app/noLogin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { NoLoginSelfHeal } from '@/src/components/NoLoginSelfHeal'
import Link from 'next/link'

interface NoLoginPageProps {
searchParams?: Promise<{ redirectTo?: string | string[] }>
searchParams?: Promise<{
redirectTo?: string | string[]
lti?: string | string[]
}>
}

function getChatRedirectUrl(redirectTo: string | undefined) {
Expand All @@ -28,6 +32,9 @@ export default async function Page({ searchParams }: NoLoginPageProps) {
const redirectTo = Array.isArray(redirectToParam)
? redirectToParam[0]
: redirectToParam
const ltiParam = resolvedSearchParams.lti
const isLtiContext =
(Array.isArray(ltiParam) ? ltiParam[0] : ltiParam) === '1'

const loginBaseUrl = process.env.NEXT_PUBLIC_PWA_URL
? process.env.NEXT_PUBLIC_PWA_URL.replace(/\/$/, '')
Expand All @@ -40,14 +47,28 @@ export default async function Page({ searchParams }: NoLoginPageProps) {

return (
<div className="bg-muted flex min-h-screen w-full items-center justify-center px-4">
<NoLoginSelfHeal redirectTo={redirectTo} />
<div className="bg-card w-full max-w-lg rounded-lg border p-8 text-center shadow-sm">
<h1 className="text-foreground text-2xl font-semibold">
Login Required
</h1>
<p className="text-muted-foreground mt-4 text-base">
You need to create a KlickerUZH account or log in before you can
access this chatbot.
</p>
{isLtiContext ? (
<>
<p className="text-muted-foreground mt-4 text-base">
Your LTI session could not be verified. The link may have expired
or be invalid.
</p>
<p className="text-muted-foreground mt-2 text-sm">
Please return to your LMS and re-launch the chatbot, or sign in
with a KlickerUZH account.
</p>
</>
) : (
<p className="text-muted-foreground mt-4 text-base">
You need to create a KlickerUZH account or log in before you can
access this chatbot.
</p>
)}
{redirectUrl && (
<p className="text-muted-foreground mt-2 text-sm">
After logging in, return to{' '}
Expand Down
Loading
Loading