Skip to content

Commit 9814033

Browse files
authored
Merge pull request #605 from microboxlabs/based/602-chat-harness-auth
Based/602 chat harness auth
2 parents 06a8ea9 + f487495 commit 9814033

5 files changed

Lines changed: 123 additions & 93 deletions

File tree

turbo-repo/apps/app/src/app/[lang]/sign-in/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,16 @@ function buildSamlLabels(
7676
};
7777
}
7878

79-
export default async function SignInPage(params: ParamsWithLang) {
79+
export default async function SignInPage(
80+
params: ParamsWithLang & {
81+
searchParams?: Promise<{ callbackUrl?: string }>;
82+
}
83+
) {
8084
const { lang } = await params.params;
85+
// Post-sign-in destination (e.g. the CLI auth handoff page). Resolved
86+
// server-side and passed as a prop so FormSignIn stays free of
87+
// useSearchParams (which would require a Suspense boundary).
88+
const callbackUrl = (await params.searchParams)?.callbackUrl ?? null;
8189
const [dict, , dictDynamic] = await getDictionary(lang);
8290
const signInMessages = buildSignInFormMessages({ messages: dict });
8391
const orgLogo = await getPublicOrgLogo();
@@ -116,6 +124,7 @@ export default async function SignInPage(params: ParamsWithLang) {
116124
providerLabels={providerLabels}
117125
dividerText={dividerText}
118126
samlLabels={samlLabels}
127+
callbackUrl={callbackUrl}
119128
/>
120129
</Card>
121130
</div>

turbo-repo/apps/app/src/app/cli/auth/login/page.tsx

Lines changed: 0 additions & 89 deletions
This file was deleted.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
}

turbo-repo/apps/app/src/features/auth/components/form-sign-in/form-sign-in.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from "@/features/auth/services/auth.service";
88
import { FormSignInProps } from "./form-sign-in.types";
99
import React, { useActionState, useEffect, useState, useCallback } from "react";
10-
import { useSearchParams } from "next/navigation";
1110
import { useForm } from "react-hook-form";
1211
import { zodResolver } from "@hookform/resolvers/zod";
1312
import { formSchema, FormSchema } from "../../services/auth.service.types";
@@ -22,15 +21,14 @@ export default function FormSignIn({
2221
providerLabels,
2322
dividerText,
2423
samlLabels,
24+
callbackUrl,
2525
}: FormSignInProps) {
2626
const form = useForm<FormSchema>({
2727
resolver: zodResolver(formSchema),
2828
defaultValues: { email: "", password: "" },
2929
});
3030

3131
const { register } = form;
32-
// Preserve the post-sign-in destination (e.g. the CLI auth handoff page)
33-
const callbackUrl = useSearchParams().get("callbackUrl");
3432
const [_state, formAction] = useActionState(authenticateAction, {});
3533
const [pending, setPending] = useState(false);
3634
const [showCredentialsForm, setShowCredentialsForm] = useState(false);

turbo-repo/apps/app/src/features/auth/components/form-sign-in/form-sign-in.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,8 @@ export type FormSignInProps = Readonly<{
3838
dividerText: string;
3939
/** SAML-specific labels (only needed if SAML provider is configured) */
4040
samlLabels?: SamlLabels;
41+
/** Post-sign-in destination (e.g. the CLI auth handoff page), passed
42+
* down from the page's searchParams so the client component doesn't
43+
* need useSearchParams (which requires a Suspense boundary). */
44+
callbackUrl?: string | null;
4145
}>;

0 commit comments

Comments
 (0)