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
95 changes: 95 additions & 0 deletions src/app/agent-login/AgentLoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function AgentLoginForm() {
const router = useRouter();
const [passportId, setPassportId] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);

try {
const res = await fetch("/api/auth/agentpass-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passportId: passportId.trim(), privateKey: privateKey.trim() }),
});

const data = await res.json();

if (!res.ok) {
setError(data.error || "Authentication failed");
setIsLoading(false);
return;
}

// Redirect to magic link confirm URL to establish session
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
return;
}

router.push("/dashboard");
router.refresh();
} catch {
setError("Network error. Please try again.");
setIsLoading(false);
}
};

return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}

<div className="space-y-2">
<Label htmlFor="passportId">Passport ID</Label>
<Input
id="passportId"
type="text"
placeholder="ap_a622a643aa71"
value={passportId}
onChange={(e) => setPassportId(e.target.value)}
disabled={isLoading}
required
/>
<p className="text-xs text-muted-foreground">
Your AgentPass passport identifier
</p>
</div>

<div className="space-y-2">
<Label htmlFor="privateKey">Private Key</Label>
<Input
id="privateKey"
type="password"
placeholder="Your AgentPass private key"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
disabled={isLoading}
required
/>
<p className="text-xs text-muted-foreground">
Used to sign the authentication request. Never stored.
</p>
</div>

<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign In with AgentPass"}
</Button>
</form>
);
}
45 changes: 45 additions & 0 deletions src/app/agent-login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AgentLoginForm } from "./AgentLoginForm";
import Link from "next/link";

export const metadata = {
title: "Agent Login | ugig.net",
description: "Sign in with your AgentPass passport",
};

export default function AgentLoginPage() {
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<Link href="/" className="text-3xl font-bold text-primary">
ugig.net
</Link>
<h1 className="mt-6 text-2xl font-bold">Agent Login</h1>
<p className="mt-2 text-muted-foreground">
Sign in using your AgentPass passport
</p>
</div>

<div className="bg-card border border-border rounded-lg p-6 shadow-sm">
<AgentLoginForm />
</div>

<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an AgentPass?{" "}
<a
href="https://agentpass.space"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Get one here
</a>
{" · "}
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
</div>
</div>
);
}
216 changes: 216 additions & 0 deletions src/app/api/auth/agentpass-login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* AgentPass Browser Login
* POST /api/auth/agentpass-login
*
* Accepts passport ID + private key from the browser form,
* generates HMAC signature server-side, verifies via AgentPass API,
* finds/creates user, and establishes session via Supabase magic link.
*/
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { createHmac, randomBytes } from "crypto";
import { fetchPassport, verifySignature } from "@/lib/auth/agentpass";
import { sendEmail } from "@/lib/email";
import { generateApiKey, hashApiKey, getKeyPrefix } from "@/lib/api-keys";

function getAdminSupabase() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
}

export async function POST(request: NextRequest) {
try {
const { passportId, privateKey } = await request.json();

if (!passportId || !privateKey) {
return NextResponse.json(
{ error: "Passport ID and private key are required" },
{ status: 400 }
);
}

// Fetch passport from AgentPass API
const passport = await fetchPassport(passportId);
if (!passport || !passport.public_key) {
return NextResponse.json(
{ error: "Passport not found or missing public key" },
{ status: 401 }
);
}

if (passport.status && passport.status !== "active") {
return NextResponse.json(
{ error: "Passport is not active" },
{ status: 401 }
);
}

// Generate signature using the provided private key and verify it
const timestamp = Date.now().toString();
const payload = `${passportId}:${timestamp}`;
const signature = createHmac("sha256", privateKey).update(payload).digest("hex");

if (!verifySignature(passportId, timestamp, signature, passport.public_key)) {
return NextResponse.json(
{ error: "Invalid private key — signature verification failed" },
{ status: 401 }
);
}

// Auth verified — find or create user
const supabase = getAdminSupabase();
const email = passport.email;

if (!email) {
return NextResponse.json(
{ error: "Passport has no email — cannot create account" },
{ status: 400 }
);
}

// Check for existing user by agentpass_id or email
let userId: string | null = null;

const { data: byPassport } = await supabase
.from("profiles")
.select("id")
.eq("agentpass_id" as any, passportId)
.maybeSingle();

if (byPassport) {
userId = byPassport.id;
} else {
const { data: byEmail } = await supabase
.from("profiles")
.select("id")
.eq("email", email)
.maybeSingle();

if (byEmail) {
userId = byEmail.id;
// Link passport ID
await supabase
.from("profiles")
.update({ agentpass_id: passportId } as any)
.eq("id", byEmail.id);
}
}

// Create new user if not found
if (!userId) {
const username = `ap_${passportId.replace(/^ap_/, "").slice(0, 12)}`;
const displayName = passport.name || `Agent ${passportId.slice(-6)}`;
const randomPassword = randomBytes(32).toString("hex");

const { data: authData, error: authError } = await supabase.auth.admin.createUser({
email,
password: randomPassword,
email_confirm: true,
user_metadata: {
username,
account_type: "agent",
agent_name: displayName,
agentpass_id: passportId,
oauth_provider: "agentpass",
},
});

if (authError && !authError.message?.includes("already been registered")) {
console.error("[AgentPass Login] Failed to create user:", authError.message);
return NextResponse.json(
{ error: "Failed to create account" },
{ status: 500 }
);
}

if (authData?.user) {
userId = authData.user.id;

await supabase.from("profiles").upsert(
{
id: userId,
email,
username,
full_name: displayName,
display_name: displayName,
account_type: "agent",
agent_name: displayName,
agentpass_id: passportId,
profile_completed: false,
} as any,
{ onConflict: "id" }
);

await (supabase as any).from("oauth_identities").insert({
user_id: userId,
provider: "agentpass",
provider_user_id: passportId,
email,
metadata: { name: displayName, agentpass_id: passportId },
});

// Generate API key
const rawKey = generateApiKey();
const keyHash = await hashApiKey(rawKey);
const keyPrefix = getKeyPrefix(rawKey);
await supabase.from("api_keys").insert({
user_id: userId,
name: "AgentPass Auto Key",
key_hash: keyHash,
key_prefix: keyPrefix,
});
} else {
// User exists via email — find them
let page = 1;
while (!userId) {
const { data: { users } } = await supabase.auth.admin.listUsers({ page, perPage: 100 });
if (!users || users.length === 0) break;
const found = users.find((u: any) => u.email?.toLowerCase() === email.toLowerCase());
if (found) {
userId = found.id;
await supabase
.from("profiles")
.update({ agentpass_id: passportId } as any)
.eq("id", found.id);
}
page++;
}
}
}

if (!userId) {
return NextResponse.json(
{ error: "Failed to find or create account" },
{ status: 500 }
);
}

// Generate magic link to establish browser session
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net";
const { data: linkData, error: linkError } = await supabase.auth.admin.generateLink({
type: "magiclink",
email,
});

if (linkError || !linkData?.properties?.hashed_token) {
console.error("[AgentPass Login] Magic link generation failed:", linkError?.message);
return NextResponse.json(
{ error: "Session creation failed" },
{ status: 500 }
);
}

const confirmUrl = `${appUrl}/auth/confirm?token_hash=${linkData.properties.hashed_token}&type=magiclink&next=/dashboard`;

return NextResponse.json({ redirectUrl: confirmUrl });
} catch (err) {
console.error("[AgentPass Login] Unexpected error:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
11 changes: 11 additions & 0 deletions src/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ export function LoginForm() {
Sign in with CoinPay
</a>

{/* AgentPass Login */}
<a
href="/agent-login"
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<svg className="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Sign in with AgentPass
</a>

<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/signup" className="text-primary hover:underline">
Expand Down