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
17,276 changes: 17,276 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.2.2",
Expand Down Expand Up @@ -45,6 +47,7 @@
"eslint-config-next": "16.2.6",
"postcss": "8.5.8",
"tailwindcss": "4.1.17",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^2.1.9"
}
}
37 changes: 33 additions & 4 deletions src/app/api/auth/challenge/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,37 @@
import { NextRequest, NextResponse } from "next/server"
import { StrKey } from "@stellar/stellar-sdk"
import { storeChallenge } from "@/lib/auth/challenge-store"
import { randomBytes } from "crypto"

/**
* POST /api/auth/challenge
*
* Issues a SEP-10 challenge for the given Stellar public key.
* The challenge is stored server-side with a 5-minute TTL so it can be
* validated at verify time, preventing forged or replayed challenges.
*
* Body: { publicKey: string }
* Response: { challenge: string }
*/
export async function POST(req: NextRequest) {
const { publicKey } = await req.json()
if (!publicKey) return NextResponse.json({ message: "publicKey required" }, { status: 400 })
const challenge = `rentar.io - SEP-10 challenge for ${publicKey} at ${Date.now()} - Nonce: ${Math.random().toString(36).slice(2)}`
return NextResponse.json({ challenge, token: `challenge_token_${Date.now()}` })
const body = await req.json().catch(() => null)
const publicKey: unknown = body?.publicKey

if (typeof publicKey !== "string" || !publicKey.trim()) {
return NextResponse.json({ message: "publicKey required" }, { status: 400 })
}

// Validate it is a well-formed Stellar public key (Ed25519)
if (!StrKey.isValidEd25519PublicKey(publicKey)) {
return NextResponse.json({ message: "Invalid Stellar public key" }, { status: 400 })
}

// Build a challenge that matches what Freighter / WalletConnect will sign:
// a UTF-8 string the client signs with Keypair.sign() (Buffer.from(challenge)).
const nonce = randomBytes(32).toString("hex")
const challenge = `rentar.io SEP-10 auth | ${publicKey} | ${Date.now()} | ${nonce}`

storeChallenge(publicKey, challenge)

return NextResponse.json({ challenge })
}
42 changes: 35 additions & 7 deletions src/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,43 @@
import { NextRequest, NextResponse } from "next/server"
import { verifyJwt } from "@/lib/auth/jwt"

/**
* GET /api/auth/me
*
* Returns the authenticated user derived from the Bearer JWT.
* The JWT signature is cryptographically validated; expired or tampered
* tokens are rejected with 401.
*
* Header: Authorization: Bearer <token>
* Response 200: { id, publicKey, displayName, email, createdAt, kycStatus }
* Response 401: missing / invalid / expired token
*/
export async function GET(req: NextRequest) {
const auth = req.headers.get("authorization")
if (!auth) return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
// decode mock
if (!auth?.startsWith("Bearer ")) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

const token = auth.slice(7) // strip "Bearer "
const result = verifyJwt(token)

if (!result.ok) {
const messages: Record<typeof result.reason, string> = {
malformed: "Malformed token",
invalid_signature: "Invalid token",
expired: "Token has expired",
}
return NextResponse.json({ message: messages[result.reason] }, { status: 401 })
}

const { sub: publicKey } = result.payload

return NextResponse.json({
id: "user_demo",
publicKey: "GDEMO...",
displayName: "Demo User",
email: "demo@rentar.io",
id: `user_${publicKey.slice(0, 8)}`,
publicKey,
displayName: `Stellar User ${publicKey.slice(0, 6)}`,
email: `${publicKey.slice(0, 8).toLowerCase()}@rentar.demo`,
createdAt: new Date().toISOString(),
kycStatus: "verified"
kycStatus: "verified",
})
}
78 changes: 70 additions & 8 deletions src/app/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,80 @@
import { NextRequest, NextResponse } from "next/server"
import { Keypair, StrKey } from "@stellar/stellar-sdk"
import { consumeChallenge } from "@/lib/auth/challenge-store"
import { signJwt } from "@/lib/auth/jwt"

/**
* POST /api/auth/verify
*
* Verifies a SEP-10 challenge response:
* 1. Looks up the stored challenge for the public key (TTL + replay protection)
* 2. Validates the Ed25519 signature produced by the client's Stellar keypair
* 3. Issues a signed HS256 JWT on success
*
* Body: { publicKey: string, signedChallenge: string (base64) }
* Response 200: { token: string, user: { ... } }
* Response 400: missing / malformed params
* Response 401: invalid signature, expired challenge, or replay attempt
*/
export async function POST(req: NextRequest) {
const { publicKey, signedChallenge } = await req.json()
if (!publicKey || !signedChallenge) return NextResponse.json({ message: "Missing params" }, { status: 400 })
const token = Buffer.from(`${publicKey}:${signedChallenge}:${Date.now()}`).toString("base64")
const body = await req.json().catch(() => null)
const { publicKey, signedChallenge } = (body ?? {}) as Record<string, unknown>

if (typeof publicKey !== "string" || typeof signedChallenge !== "string") {
return NextResponse.json({ message: "publicKey and signedChallenge are required" }, { status: 400 })
}

// Validate public key format before touching crypto primitives
if (!StrKey.isValidEd25519PublicKey(publicKey)) {
return NextResponse.json({ message: "Invalid Stellar public key" }, { status: 400 })
}

// --- Step 1: retrieve and consume the stored challenge ---
const result = consumeChallenge(publicKey)
if (!result.ok) {
const messages: Record<typeof result.reason, string> = {
not_found: "No pending challenge for this public key — request a new challenge first",
expired: "Challenge has expired — request a new challenge",
already_used: "Challenge has already been used — request a new challenge",
}
return NextResponse.json({ message: messages[result.reason] }, { status: 401 })
}

// --- Step 2: verify the Ed25519 signature ---
let signatureBuffer: Buffer
try {
signatureBuffer = Buffer.from(signedChallenge, "base64")
if (signatureBuffer.length === 0) throw new Error("empty")
} catch {
return NextResponse.json({ message: "signedChallenge must be a valid base64-encoded signature" }, { status: 400 })
}

let isValid = false
try {
const keypair = Keypair.fromPublicKey(publicKey)
const challengeBuffer = Buffer.from(result.challenge)
isValid = keypair.verify(challengeBuffer, signatureBuffer)
} catch {
// fromPublicKey throws on an invalid key; treat as auth failure
return NextResponse.json({ message: "Invalid signature" }, { status: 401 })
}

if (!isValid) {
return NextResponse.json({ message: "Invalid signature" }, { status: 401 })
}

// --- Step 3: issue a signed JWT ---
const token = signJwt(publicKey)

return NextResponse.json({
token,
user: {
id: `user_${publicKey.slice(0,8)}`,
id: `user_${publicKey.slice(0, 8)}`,
publicKey,
displayName: `Stellar User ${publicKey.slice(0,6)}`,
email: `${publicKey.slice(0,8).toLowerCase()}@rentar.demo`,
displayName: `Stellar User ${publicKey.slice(0, 6)}`,
email: `${publicKey.slice(0, 8).toLowerCase()}@rentar.demo`,
createdAt: new Date().toISOString(),
kycStatus: "verified"
}
kycStatus: "verified",
},
})
}
68 changes: 68 additions & 0 deletions src/lib/auth/__tests__/challenge-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Challenge Store — unit/integration tests
*
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, vi } from "vitest"
import {
storeChallenge,
consumeChallenge,
_clearStore,
CHALLENGE_TTL_MS,
} from "@/lib/auth/challenge-store"

const TEST_KEY = "GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37"
const TEST_CHALLENGE = "rentar.io SEP-10 auth | nonce-abc123"

beforeEach(() => {
_clearStore()
vi.useRealTimers()
})

describe("storeChallenge / consumeChallenge", () => {
it("returns the stored challenge when valid", () => {
storeChallenge(TEST_KEY, TEST_CHALLENGE)
const result = consumeChallenge(TEST_KEY)
expect(result.ok).toBe(true)
if (result.ok) expect(result.challenge).toBe(TEST_CHALLENGE)
})

it("returns not_found when no challenge has been stored", () => {
const result = consumeChallenge(TEST_KEY)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("not_found")
})

it("enforces replay protection — second consume returns already_used", () => {
storeChallenge(TEST_KEY, TEST_CHALLENGE)
const first = consumeChallenge(TEST_KEY)
expect(first.ok).toBe(true)

const second = consumeChallenge(TEST_KEY)
expect(second.ok).toBe(false)
if (!second.ok) expect(second.reason).toBe("already_used")
})

it("returns expired when TTL has elapsed", () => {
vi.useFakeTimers()
storeChallenge(TEST_KEY, TEST_CHALLENGE)

// Advance clock past the TTL
vi.advanceTimersByTime(CHALLENGE_TTL_MS + 1)

const result = consumeChallenge(TEST_KEY)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("expired")
})

it("accepts challenge issued just before expiry", () => {
vi.useFakeTimers()
storeChallenge(TEST_KEY, TEST_CHALLENGE)

// Advance to 1ms before expiry — should still be valid
vi.advanceTimersByTime(CHALLENGE_TTL_MS - 1)

const result = consumeChallenge(TEST_KEY)
expect(result.ok).toBe(true)
})
})
81 changes: 81 additions & 0 deletions src/lib/auth/__tests__/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* JWT utility — unit tests for signJwt / verifyJwt
*
* @vitest-environment node
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { signJwt, verifyJwt } from "@/lib/auth/jwt"

const TEST_PUBLIC_KEY = "GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37"

beforeEach(() => {
vi.useRealTimers()
})

afterEach(() => {
vi.useRealTimers()
})

describe("signJwt / verifyJwt", () => {
it("signs and verifies a valid token", () => {
const token = signJwt(TEST_PUBLIC_KEY)
expect(typeof token).toBe("string")
expect(token.split(".")).toHaveLength(3)

const result = verifyJwt(token)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.payload.sub).toBe(TEST_PUBLIC_KEY)
expect(typeof result.payload.iat).toBe("number")
expect(typeof result.payload.exp).toBe("number")
expect(result.payload.exp).toBeGreaterThan(result.payload.iat)
}
})

it("rejects a token with a tampered payload", () => {
const token = signJwt(TEST_PUBLIC_KEY)
const parts = token.split(".")

// Tamper the payload (flip one char)
const tampered = parts[1].slice(0, -2) + "xx"
const tamperedToken = `${parts[0]}.${tampered}.${parts[2]}`

const result = verifyJwt(tamperedToken)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("invalid_signature")
})

it("rejects a token with a tampered signature", () => {
const token = signJwt(TEST_PUBLIC_KEY)
const parts = token.split(".")
const badSig = parts[2].slice(0, -2) + "zz"
const tamperedToken = `${parts[0]}.${parts[1]}.${badSig}`

const result = verifyJwt(tamperedToken)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("invalid_signature")
})

it("rejects a malformed token (not 3 parts)", () => {
const result = verifyJwt("not.a.valid.jwt.token")
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("malformed")
})

it("rejects an expired token", () => {
vi.useFakeTimers()
const token = signJwt(TEST_PUBLIC_KEY)

// Advance past 24-hour expiry
vi.advanceTimersByTime(25 * 60 * 60 * 1000)

const result = verifyJwt(token)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("expired")
})

it("rejects an empty string", () => {
const result = verifyJwt("")
expect(result.ok).toBe(false)
})
})
Loading