Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ PYMTHOUSE_ISSUER_URL=http://localhost:3001/api/v1/oidc
PYMTHOUSE_PUBLIC_CLIENT_ID=
PYMTHOUSE_M2M_CLIENT_ID=
PYMTHOUSE_M2M_CLIENT_SECRET=
# Remote signer url base returned to CLI clients after API-key exchange.
# Local pymthouse compose: http://127.0.0.1:8080 or hosted signer

# Initiate login URI for device flow (register on pymthouse app):
# http://localhost:3002/api/auth/initiate-login
# Remote signer url base returned to CLI clients after API-key exchange.
PYMTHOUSE_SIGNER_URL=http://127.0.0.1:8080

# Local pymthouse compose: http://127.0.0.1:8080
# Set to 1 for local dev when issuer uses http://127.0.0.1
PYMTHOUSE_ALLOW_INSECURE_HTTP=
29 changes: 29 additions & 0 deletions app/(auth)/device-approved/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Link from "next/link";
import { LivepeerSymbol } from "@/components/design-system/LivepeerLogo";

export default function DeviceApprovedPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-dark px-6">
<div className="w-full max-w-md rounded-xl border border-hairline bg-dark-card p-8 text-center">
<div className="mb-5 flex justify-center">
<LivepeerSymbol className="h-9 w-9" />
</div>
<h1 className="text-2xl font-semibold tracking-tight text-fg">
Device login approved
</h1>
<p className="mt-3 text-sm leading-relaxed text-fg-muted">
You can return to your terminal. The python-gateway device flow should
finish automatically in a few seconds.
</p>
<div className="mt-6">
<Link
href="/home"
className="inline-flex rounded-full bg-green-bright px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-light"
>
Open dashboard
</Link>
</div>
</div>
</main>
);
}
22 changes: 16 additions & 6 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/components/dashboard/AuthContext";
import LoginPage from "@/components/dashboard/LoginPage";

export default function LoginRoute() {
function LoginRouteInner() {
const { isConnected } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const deviceFlow = searchParams.get("flow") === "device";

useEffect(() => {
if (isConnected) {
if (isConnected && !deviceFlow) {
router.replace("/home");
}
}, [isConnected, router]);
}, [isConnected, deviceFlow, router]);

if (isConnected) return null;
if (isConnected && !deviceFlow) return null;

return <LoginPage />;
}

export default function LoginRoute() {
return (
<Suspense fallback={null}>
<LoginRouteInner />
</Suspense>
);
}
71 changes: 71 additions & 0 deletions app/api/auth/device/complete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { PmtHouseError } from "@pymthouse/builder-sdk";
import {
clearDeviceFlowCookie,
completeDashboardDeviceApproval,
readDeviceFlowCookie,
} from "@/lib/dashboard/device-flow";

export const runtime = "nodejs";

export async function POST(request: NextRequest) {
const deviceFlow = await readDeviceFlowCookie();
if (!deviceFlow) {
return NextResponse.json(
{ error: "no_pending_device_flow" },
{ status: 400 },
);
}

let body: { externalUserId?: string; email?: string; name?: string };
try {
body = (await request.json()) as typeof body;
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}

const email = body.email?.trim() ?? "";
const externalUserId = body.externalUserId?.trim() || email;
if (!externalUserId) {
return NextResponse.json(
{ error: "invalid_request", error_description: "email is required" },
{ status: 400 },
);
}

try {
await completeDashboardDeviceApproval({
userCode: deviceFlow.userCode,
publicClientId: deviceFlow.clientId,
externalUserId,
email: email || externalUserId,
});

await clearDeviceFlowCookie();

return NextResponse.json({
success: true,
redirectTo: "/device-approved",
});
} catch (error) {
console.error("Device approval completion failed", error);
if (error instanceof PmtHouseError) {
return NextResponse.json(
{
error: error.code,
error_description: error.message,
},
{ status: error.status },
);
}

return NextResponse.json(
{
error: "device_approval_failed",
error_description:
error instanceof Error ? error.message : "Unknown device approval error",
},
{ status: 500 },
);
}
}
58 changes: 58 additions & 0 deletions app/api/auth/initiate-login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import {
extractDeviceApprovalFromTargetLink,
validateDeviceInitiateLogin,
} from "@pymthouse/builder-sdk/device-initiate";
import { setDeviceFlowCookie } from "@/lib/dashboard/device-flow";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim();
if (!issuerUrl) {
return NextResponse.json(
{
error: "server_misconfigured",
error_description: "PYMTHOUSE_ISSUER_URL is required",
},
{ status: 503 },
);
}

const iss = request.nextUrl.searchParams.get("iss")?.trim() ?? "";
const targetLinkUri =
request.nextUrl.searchParams.get("target_link_uri")?.trim() ?? "";

const validation = validateDeviceInitiateLogin({
expectedIssuerUrl: issuerUrl,
iss,
targetLinkUri,
});
if (!validation.ok) {
return NextResponse.json(
{ error: "invalid_request", error_description: validation.reason },
{ status: 400 },
);
}

const extracted = extractDeviceApprovalFromTargetLink(targetLinkUri, {
expectedIssuerUrl: issuerUrl,
});
if ("error" in extracted) {
return NextResponse.json(
{ error: "invalid_request", error_description: extracted.error },
{ status: 400 },
);
}

await setDeviceFlowCookie({
iss,
targetLinkUri,
userCode: extracted.userCode,
clientId: extracted.publicClientId,
});

const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("flow", "device");
return NextResponse.redirect(loginUrl);
}
38 changes: 38 additions & 0 deletions app/api/signer/device/exchange/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createDeviceExchangeHandler } from "@pymthouse/builder-sdk/signer/server";

function readDeviceExchangeConfig() {
const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim();
const m2mClientId = process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim();
const m2mClientSecret = process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim();
if (!issuerUrl || !m2mClientId || !m2mClientSecret) {
return null;
}
const signerUrl =
process.env.PYMTHOUSE_SIGNER_URL?.trim() ||
process.env.SIGNER_PUBLIC_URL?.trim() ||
undefined;
return {
issuerUrl,
m2mClientId,
m2mClientSecret,
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
signerUrl,
};
}

export async function POST(request: Request) {
const config = readDeviceExchangeConfig();
if (!config) {
return Response.json(
{
error: "server_misconfigured",
error_description:
"PYMTHOUSE_ISSUER_URL, PYMTHOUSE_M2M_CLIENT_ID, and PYMTHOUSE_M2M_CLIENT_SECRET are required",
},
{ status: 503 },
);
}

const handler = createDeviceExchangeHandler(config);
return handler(request);
}
Loading