Skip to content

[wip] auth #7

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
25 changes: 25 additions & 0 deletions app/(oauth)/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";

export async function GET(request: Request) {
const metadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
registration_endpoint: "http://localhost:3000/oauth/register",
scopes_supported: ["read_write"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
token_endpoint_auth_methods_supported: ["client_secret_basic"],
code_challenge_methods_supported: ["S256"],
service_documentation:
"https://docs.stripe.com/stripe-apps/api-authentication/oauth",
};

return new NextResponse(JSON.stringify(metadata), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}
51 changes: 51 additions & 0 deletions app/(oauth)/api/auth/#/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SignJWT } from "jose";

// In a real app, you would validate against a database
const VALID_CREDENTIALS = {
email: "test@example.com",
password: "password123",
};

export async function POST(request: Request) {
try {
const body = await request.json();
const { email, password } = body;

// Validate credentials
if (
email !== VALID_CREDENTIALS.email ||
password !== VALID_CREDENTIALS.password
) {
return new NextResponse(
JSON.stringify({ error: "Invalid credentials" }),
{ status: 401 }
);
}

// Generate a JWT token
const secret = new TextEncoder().encode(
process.env.JWT_SECRET || "your-secret-key"
);

const token = await new SignJWT({ email })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("24h")
.sign(secret);

// Return the token
return new NextResponse(JSON.stringify({ token }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
return new NextResponse(
JSON.stringify({ error: "Internal server error" }),
{ status: 500 }
);
}
}
33 changes: 33 additions & 0 deletions app/(oauth)/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function POST(request: Request) {
try {
const body = await request.json();
const { email, password } = body;

// Accept any non-empty email and password
if (!email || !password) {
return new NextResponse(
JSON.stringify({ error: "Email and password are required" }),
{ status: 400 }
);
}

// Set the session cookie with the email
const cookieStore = await cookies();
cookieStore.set("auth_session", email, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24, // 24 hours
});

return new NextResponse(JSON.stringify({ success: true }), { status: 200 });
} catch (error) {
return new NextResponse(
JSON.stringify({ error: "Internal server error" }),
{ status: 500 }
);
}
}
141 changes: 141 additions & 0 deletions app/(oauth)/authorize/#/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";

export default function AuthorizeLoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);

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

try {
const response = await fetch("/api/auth/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});

if (!response.ok) {
throw new Error("Invalid credentials");
}

// Get all the original OAuth parameters
const client_id = searchParams.get("client_id");
const redirect_uri = searchParams.get("redirect_uri");
const response_type = searchParams.get("response_type");
const code_challenge = searchParams.get("code_challenge");
const code_challenge_method = searchParams.get("code_challenge_method");
const state = searchParams.get("state");

// Redirect back to authorize with all parameters
const authorizeUrl = new URL("/authorize", window.location.origin);
authorizeUrl.searchParams.set("client_id", client_id || "");
authorizeUrl.searchParams.set("redirect_uri", redirect_uri || "");
authorizeUrl.searchParams.set("response_type", response_type || "");
if (code_challenge)
authorizeUrl.searchParams.set("code_challenge", code_challenge);
if (code_challenge_method)
authorizeUrl.searchParams.set(
"code_challenge_method",
code_challenge_method
);
if (state) authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("authenticated", "true");

// Use window.location.href for a full page navigation
window.location.href = authorizeUrl.toString();
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsLoading(false);
}
};

return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-purple-50">
<div className="max-w-md w-full mx-4">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="px-8 pt-8 pb-6 bg-gradient-to-r from-blue-500 to-purple-500">
<h2 className="text-center text-3xl font-bold text-white">
Welcome Back
</h2>
<p className="mt-2 text-center text-sm text-blue-100">
Please # to continue
</p>
</div>

<div className="p-8">
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="p-4 text-sm text-red-500 bg-red-50 rounded-xl border border-red-100 text-center">
{error}
</div>
)}

<div className="space-y-5">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
required
className="block w-full px-4 py-3 border border-gray-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="block w-full px-4 py-3 border border-gray-200 rounded-xl shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
</div>

<div>
<button
type="submit"
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoading}
>
{isLoading ? "Signing in..." : "#"}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}
138 changes: 138 additions & 0 deletions app/(oauth)/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SignJWT } from "jose";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);

// Required OAuth parameters
const client_id = searchParams.get("client_id");
const redirect_uri = searchParams.get("redirect_uri");
const response_type = searchParams.get("response_type");
const code_challenge = searchParams.get("code_challenge");
const code_challenge_method = searchParams.get("code_challenge_method");
const state = "123";
const authenticated = searchParams.get("authenticated");

// Check which parameters are missing
const missingParams = [];
if (!client_id) missingParams.push("client_id");
if (!redirect_uri) missingParams.push("redirect_uri");
if (!response_type) missingParams.push("response_type");

// Validate required parameters
if (missingParams.length > 0) {
return new NextResponse(
JSON.stringify({
error: "invalid_request",
error_description: `Missing required parameters: ${missingParams.join(
", "
)}`,
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

// Validate response_type
if (response_type !== "code") {
return new NextResponse(
JSON.stringify({
error: "unsupported_response_type",
error_description: "Only code response type is supported",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

// Validate PKCE if provided
if (code_challenge && code_challenge_method !== "S256") {
return new NextResponse(
JSON.stringify({
error: "invalid_request",
error_description: "Only S256 code challenge method is supported",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

// Check for authentication
if (authenticated !== "true") {
// Redirect to login page with all parameters
const loginUrl = new URL("/authorize/#", request.url);
searchParams.forEach((value, key) => {
if (value) {
loginUrl.searchParams.set(key, value);
}
});
return NextResponse.redirect(loginUrl.toString());
}

// At this point, we know the user is authenticated
// Get the email from the session cookie
const cookieStore = await cookies();
const authSession = cookieStore.get("auth_session");
if (!authSession?.value) {
return new NextResponse(
JSON.stringify({
error: "unauthorized",
error_description: "No authenticated session found",
}),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}

// Generate authorization code
const code = await generateAuthorizationCode(
client_id,
"read_write", // Default scope
code_challenge,
authSession.value
);

// Store the code and its details in a secure way (e.g., database)
// For this example, we'll use cookies
cookieStore.set("auth_code", code, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10, // 10 minutes
});

// Redirect to the client's redirect URI with the authorization code
if (!redirect_uri) {
throw new Error("Redirect URI is required");
}

const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set("code", code);
redirectUrl.searchParams.set("state", state);

return NextResponse.redirect(redirectUrl.toString());
}

async function generateAuthorizationCode(
client_id: string | null,
scope: string,
code_challenge?: string | null,
email?: string
) {
if (!client_id) {
throw new Error("Client ID is required");
}

const secret = new TextEncoder().encode(
process.env.JWT_SECRET || "your-secret-key"
);

return new SignJWT({
client_id,
scope,
code_challenge,
email,
timestamp: Date.now(),
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("10m")
.sign(secret);
}
Loading