diff --git a/app/space/page.tsx b/app/space/page.tsx index c16a326..3d1f00f 100644 --- a/app/space/page.tsx +++ b/app/space/page.tsx @@ -3,6 +3,8 @@ import dynamic from 'next/dynamic' import Link from 'next/link' import { useState, useCallback, useEffect, useRef } from 'react' +import { useAuthStore } from '@/lib/auth' +import { AuthButton } from '@/components/AuthButton' import { starterNodes } from '@/lib/presets' import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state' import type { FlowNode, SpaceConfig, IntegrationConfig } from '@/lib/types' @@ -30,6 +32,7 @@ const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), { }) export default function SpacePage() { + const { isAuthenticated, token } = useAuthStore() const [currentNodes, setCurrentNodes] = useState(starterNodes) const [integrations, setIntegrations] = useState() const [spaceName, setSpaceName] = useState('') @@ -238,6 +241,15 @@ export default function SpacePage() { showStatus(`Loaded flow "${flow.name}" (${flow.status})`, 'success') }, [showStatus]) + // Set auth cookie for middleware (so server-side can check) + useEffect(() => { + if (token) { + document.cookie = `encryptid_token=${token}; path=/; max-age=86400; SameSite=Lax` + } else { + document.cookie = 'encryptid_token=; path=/; max-age=0' + } + }, [token]) + if (!loaded) { return (
@@ -249,6 +261,21 @@ export default function SpacePage() { ) } + if (!isAuthenticated) { + return ( +
+
+

Sign in to access your Space

+

EncryptID passkey authentication is required to create and edit funding flows.

+
+ + + Back to home + +
+ ) + } + return (
{/* Status Toast */} @@ -271,6 +298,8 @@ export default function SpacePage() { | Your Space + | + {deployedFlow && ( <> | diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx new file mode 100644 index 0000000..8a0abcb --- /dev/null +++ b/components/AuthButton.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useState } from 'react' +import { useAuthStore } from '@/lib/auth' + +export function AuthButton() { + const { isAuthenticated, username, loading, login, register, logout } = useAuthStore() + const [showRegister, setShowRegister] = useState(false) + const [registerName, setRegisterName] = useState('') + + const handleLogin = async () => { + try { + await login() + } catch (e: unknown) { + if (e instanceof Error && e.name === 'NotAllowedError') { + setShowRegister(true) + } + } + } + + const handleRegister = async () => { + if (!registerName.trim()) return + try { + await register(registerName.trim()) + setShowRegister(false) + setRegisterName('') + } catch (e: unknown) { + alert('Registration failed: ' + (e instanceof Error ? e.message : 'Unknown error')) + } + } + + if (loading) { + return Authenticating... + } + + if (isAuthenticated) { + return ( +
+ + Signed in as {username} + + +
+ ) + } + + if (showRegister) { + return ( +
+ setRegisterName(e.target.value)} + placeholder="Choose a username" + className="px-2 py-1 text-sm border rounded" + onKeyDown={(e) => e.key === 'Enter' && handleRegister()} + /> + + +
+ ) + } + + return ( + + ) +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..fc8a38e --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,185 @@ +/** + * EncryptID Auth Store for rfunds-online + * + * Provides passkey-based identity for space creation and editing. + * Uses Zustand with localStorage persistence. + */ + +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com' + +interface AuthState { + isAuthenticated: boolean + token: string | null + did: string | null + username: string | null + loading: boolean + + login: () => Promise + register: (username: string) => Promise + logout: () => void +} + +function toBase64url(buffer: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +function fromBase64url(str: string): Uint8Array { + return Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) +} + +export const useAuthStore = create()( + persist( + (set) => ({ + isAuthenticated: false, + token: null, + did: null, + username: null, + loading: false, + + login: async () => { + set({ loading: true }) + try { + const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const { options } = await startRes.json() + + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + challenge: fromBase64url(options.challenge).buffer as ArrayBuffer, + rpId: options.rpId, + userVerification: options.userVerification as UserVerificationRequirement, + timeout: options.timeout, + allowCredentials: options.allowCredentials?.map((c: { type: string; id: string; transports: string[] }) => ({ + type: c.type as PublicKeyCredentialType, + id: fromBase64url(c.id).buffer as ArrayBuffer, + transports: c.transports as AuthenticatorTransport[], + })), + } + + const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential + if (!assertion) throw new Error('Authentication cancelled') + + const response = assertion.response as AuthenticatorAssertionResponse + + const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: options.challenge, + credential: { + credentialId: assertion.id, + authenticatorData: toBase64url(response.authenticatorData), + clientDataJSON: toBase64url(response.clientDataJSON), + signature: toBase64url(response.signature), + userHandle: response.userHandle ? toBase64url(response.userHandle) : null, + }, + }), + }) + + const result = await completeRes.json() + if (!result.success) throw new Error(result.error || 'Authentication failed') + + set({ + isAuthenticated: true, + token: result.token, + did: result.did, + username: result.username, + loading: false, + }) + } catch (error) { + set({ loading: false }) + throw error + } + }, + + register: async (username: string) => { + set({ loading: true }) + try { + const startRes = await fetch(`${ENCRYPTID_SERVER}/api/register/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, displayName: username }), + }) + const { options, userId } = await startRes.json() + + const publicKeyOptions: PublicKeyCredentialCreationOptions = { + challenge: fromBase64url(options.challenge).buffer as ArrayBuffer, + rp: options.rp, + user: { + id: fromBase64url(options.user.id).buffer as ArrayBuffer, + name: options.user.name, + displayName: options.user.displayName, + }, + pubKeyCredParams: options.pubKeyCredParams, + authenticatorSelection: options.authenticatorSelection, + timeout: options.timeout, + attestation: options.attestation as AttestationConveyancePreference, + } + + const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential + if (!credential) throw new Error('Registration cancelled') + + const response = credential.response as AuthenticatorAttestationResponse + + const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/register/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: options.challenge, + userId, + username, + credential: { + credentialId: credential.id, + publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject), + attestationObject: toBase64url(response.attestationObject), + clientDataJSON: toBase64url(response.clientDataJSON), + transports: (response as AuthenticatorAttestationResponse & { getTransports?: () => string[] }).getTransports?.() || [], + }, + }), + }) + + const result = await completeRes.json() + if (!result.success) throw new Error(result.error || 'Registration failed') + + set({ + isAuthenticated: true, + token: result.token, + did: result.did, + username, + loading: false, + }) + } catch (error) { + set({ loading: false }) + throw error + } + }, + + logout: () => { + set({ + isAuthenticated: false, + token: null, + did: null, + username: null, + loading: false, + }) + }, + }), + { + name: 'rfunds-auth', + partialize: (state) => ({ + isAuthenticated: state.isAuthenticated, + token: state.token, + did: state.did, + username: state.username, + }), + } + ) +) diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..8c3ddbd --- /dev/null +++ b/middleware.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +/** + * Middleware to protect /space routes. + * + * Client-side auth enforcement: the space page itself checks auth state via + * Zustand store. This middleware adds a cookie-based check for server-rendered + * requests — if no encryptid_token cookie is present on /space, redirect to + * the home page with a login hint. + * + * Note: Since rfunds uses client-side Zustand persistence (localStorage), + * the primary auth gate is in the SpacePage component itself. This middleware + * serves as an additional layer for direct URL access. + */ +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + // Only protect /space routes (not /tbff which is a public demo) + if (pathname.startsWith('/space')) { + // Check for auth token in cookie (set by client after login) + const token = request.cookies.get('encryptid_token')?.value + + // Also check Authorization header for API-style access + const authHeader = request.headers.get('authorization') + const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null + + if (!token && !bearerToken) { + // No auth — redirect to home with login hint + // The client-side auth store is the primary gate, but this catches + // direct navigation before hydration + const url = request.nextUrl.clone() + url.pathname = '/' + url.searchParams.set('login', 'required') + url.searchParams.set('return', pathname) + return NextResponse.redirect(url) + } + } + + return NextResponse.next() +} + +export const config = { + matcher: ['/space/:path*'], +} diff --git a/package.json b/package.json index 91a931d..48d51bd 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@encryptid/sdk": "file:../encryptid-sdk", "@xyflow/react": "^12.10.0", "lz-string": "^1.5.0", "next": "14.2.35",