|
| 1 | +import { auth } from "@/auth"; |
| 2 | +import { resolveTenantScope } from "@/app/api/utils/tenant-scope"; |
| 3 | +import { createCliAuthHandoff } from "../handoff-store"; |
| 4 | +import { getLocaleFromHeaders } from "@/features/i18n/i18n.service"; |
| 5 | +import { NextResponse } from "next/server"; |
| 6 | + |
| 7 | +/** |
| 8 | + * CLI auth handoff endpoint. |
| 9 | + * |
| 10 | + * Implemented as a route handler (not a page): the handoff is pure |
| 11 | + * control flow — validate params, require a session, resolve the org, |
| 12 | + * mint a one-time code, redirect back to the CLI's loopback server — |
| 13 | + * so plain HTTP redirects are all it needs. The previous server-component |
| 14 | + * page delivered its redirect in-stream (HTTP 200 + client transition), |
| 15 | + * which crashed Next 16.2.6's client Router ("Rendered more hooks than |
| 16 | + * during the previous render") and stranded the browser on an error page. |
| 17 | + */ |
| 18 | + |
| 19 | +function parseLocalRedirectUri(value: string | null): URL | null { |
| 20 | + if (!value) return null; |
| 21 | + try { |
| 22 | + const url = new URL(value); |
| 23 | + const isLoopback = |
| 24 | + url.hostname === "127.0.0.1" || |
| 25 | + url.hostname === "localhost" || |
| 26 | + url.hostname === "[::1]" || |
| 27 | + url.hostname === "::1"; |
| 28 | + if (url.protocol !== "http:" || !isLoopback) return null; |
| 29 | + return url; |
| 30 | + } catch { |
| 31 | + return null; |
| 32 | + } |
| 33 | +} |
| 34 | + |
| 35 | +function errorRedirect( |
| 36 | + redirectUri: URL, |
| 37 | + state: string, |
| 38 | + message: string |
| 39 | +): NextResponse { |
| 40 | + redirectUri.searchParams.set("state", state); |
| 41 | + redirectUri.searchParams.set("error", "access_denied"); |
| 42 | + redirectUri.searchParams.set("error_description", message); |
| 43 | + return NextResponse.redirect(redirectUri); |
| 44 | +} |
| 45 | + |
| 46 | +const INVALID_REQUEST_HTML = `<!doctype html> |
| 47 | +<html lang="en"> |
| 48 | + <head><meta charset="utf-8"><title>ModularIoT CLI</title></head> |
| 49 | + <body style="font-family: system-ui, sans-serif; display: flex; min-height: 100vh; align-items: center; justify-content: center; background: #f8fafc; color: #0f172a;"> |
| 50 | + <section style="max-width: 28rem; border: 1px solid #e2e8f0; border-radius: 0.5rem; background: #fff; padding: 1.5rem; box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);"> |
| 51 | + <p style="margin: 0; font-size: 0.875rem; font-weight: 600; color: #2563eb;">ModularIoT CLI</p> |
| 52 | + <h1 style="margin: 0.75rem 0 0; font-size: 1.5rem;">Invalid login request</h1> |
| 53 | + <p style="margin: 0.5rem 0 0; font-size: 0.875rem; color: #475569;">The CLI callback URL or state parameter is missing or invalid.</p> |
| 54 | + </section> |
| 55 | + </body> |
| 56 | +</html>`; |
| 57 | + |
| 58 | +export async function GET(request: Request): Promise<Response> { |
| 59 | + const url = new URL(request.url); |
| 60 | + const redirectUri = parseLocalRedirectUri(url.searchParams.get("redirect_uri")); |
| 61 | + const state = url.searchParams.get("state"); |
| 62 | + |
| 63 | + if (!redirectUri || !state) { |
| 64 | + return new Response(INVALID_REQUEST_HTML, { |
| 65 | + status: 400, |
| 66 | + headers: { "content-type": "text/html; charset=utf-8" }, |
| 67 | + }); |
| 68 | + } |
| 69 | + |
| 70 | + const session = await auth(); |
| 71 | + if (!session?.user?.id) { |
| 72 | + const locale = getLocaleFromHeaders(request.headers); |
| 73 | + const callbackUrl = `/cli/auth/login?redirect_uri=${encodeURIComponent( |
| 74 | + redirectUri.toString() |
| 75 | + )}&state=${encodeURIComponent(state)}`; |
| 76 | + // Route handlers redirect with absolute URLs, so the app basePath is |
| 77 | + // included explicitly (pages got it implicitly from the router). |
| 78 | + return NextResponse.redirect( |
| 79 | + new URL( |
| 80 | + `/app/${locale}/sign-in?callbackUrl=${encodeURIComponent(callbackUrl)}`, |
| 81 | + url.origin |
| 82 | + ) |
| 83 | + ); |
| 84 | + } |
| 85 | + |
| 86 | + const scopeResult = await resolveTenantScope(); |
| 87 | + if (!scopeResult.resolved) { |
| 88 | + return errorRedirect( |
| 89 | + redirectUri, |
| 90 | + state, |
| 91 | + "Could not resolve active organization." |
| 92 | + ); |
| 93 | + } |
| 94 | + |
| 95 | + const token = session.user.rawJWT ?? session.user.ticket; |
| 96 | + if (!token) { |
| 97 | + return errorRedirect(redirectUri, state, "Current session has no API token."); |
| 98 | + } |
| 99 | + |
| 100 | + const { code } = createCliAuthHandoff({ |
| 101 | + token, |
| 102 | + organizationId: scopeResult.scope.activeOrg.slug, |
| 103 | + }); |
| 104 | + |
| 105 | + redirectUri.searchParams.set("state", state); |
| 106 | + redirectUri.searchParams.set("code", code); |
| 107 | + return NextResponse.redirect(redirectUri); |
| 108 | +} |
0 commit comments