Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions apps/blog-next/app/api-token-posts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TokenPostsClient } from './token-posts-client'

export const dynamic = 'force-dynamic'

export default function ApiTokenPostsPage() {
return <TokenPostsClient />
}
129 changes: 129 additions & 0 deletions apps/blog-next/app/api-token-posts/token-posts-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client'

import { type FormEvent, useState } from 'react'

type JsonResult = {
readonly status: number
readonly payload: unknown
}

function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

function getStringField(value: unknown, field: string): string {
if (!isRecord(value)) {
return ''
}

const fieldValue = value[field]
return typeof fieldValue === 'string' ? fieldValue : ''
}

async function readJson(response: Response): Promise<unknown> {
try {
return await response.json()
} catch {
return {
ok: false,
message: 'Response was not valid JSON.',
}
}
}

export function TokenPostsClient() {
const [tokenResult, setTokenResult] = useState<JsonResult | null>(null)
const [postsResult, setPostsResult] = useState<JsonResult | null>(null)
const [creatingToken, setCreatingToken] = useState(false)
const [fetchingPosts, setFetchingPosts] = useState(false)
const generatedToken = getStringField(tokenResult?.payload, 'token')

async function createToken(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setCreatingToken(true)
setPostsResult(null)

try {
const response = await fetch('/api/v1/tokens', {
method: 'POST',
body: new FormData(event.currentTarget),
})

setTokenResult({
status: response.status,
payload: await readJson(response),
})
} finally {
setCreatingToken(false)
}
}

async function fetchPosts(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setFetchingPosts(true)

const formData = new FormData(event.currentTarget)
const token = String(formData.get('token') ?? '').trim()

try {
const response = await fetch('/api/v1/posts', {
headers: token ? { authorization: `Bearer ${token}` } : undefined,
})

setPostsResult({
status: response.status,
payload: await readJson(response),
})
} finally {
setFetchingPosts(false)
}
}

return (
<section style={{ display: 'grid', gap: '1rem', maxWidth: '44rem' }}>
<div>
<h1 style={{ margin: '0 0 0.5rem 0' }}>API token posts</h1>
<p style={{ margin: 0, color: '#94a3b8' }}>Generate a bearer token from credentials, then use it to fetch protected posts.</p>
</div>

<form onSubmit={createToken} style={{ display: 'grid', gap: '0.9rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Create token</h2>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Email</span>
<input name="email" type="email" placeholder="editor@example.com" required />
</label>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Password</span>
<input name="password" type="password" placeholder="secret" required />
</label>
<button type="submit" disabled={creatingToken}>{creatingToken ? 'Creating...' : 'Create token'}</button>
</form>

{tokenResult ? (
<section style={{ display: 'grid', gap: '0.65rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Token response ({tokenResult.status})</h2>
{generatedToken ? (
<textarea readOnly value={generatedToken} rows={3} style={{ width: '100%', boxSizing: 'border-box' }} />
) : null}
<pre style={{ margin: 0, overflowX: 'auto' }}>{JSON.stringify(tokenResult.payload, null, 2)}</pre>
</section>
) : null}

<form onSubmit={fetchPosts} style={{ display: 'grid', gap: '0.9rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Fetch posts</h2>
<label style={{ display: 'grid', gap: '0.35rem' }}>
<span>Bearer token</span>
<textarea name="token" rows={3} required />
</label>
<button type="submit" disabled={fetchingPosts}>{fetchingPosts ? 'Fetching...' : 'Fetch posts'}</button>
</form>

{postsResult ? (
<section style={{ display: 'grid', gap: '0.65rem', padding: '1.25rem', borderRadius: '1rem', background: '#111827', border: '1px solid rgba(148, 163, 184, 0.16)' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Posts response ({postsResult.status})</h2>
<pre style={{ margin: 0, overflowX: 'auto' }}>{JSON.stringify(postsResult.payload, null, 2)}</pre>
</section>
) : null}
</section>
)
}
26 changes: 26 additions & 0 deletions apps/blog-next/app/api/v1/posts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import auth from '@holo-js/auth'

import Post from '@/server/models/Post'

export async function GET() {
const currentUser = await auth.guard('api').user()
const userId = currentUser?.id

if (typeof userId === 'undefined') {
return Response.json({
ok: false,
message: 'Unauthenticated.',
}, { status: 401 })
}

const posts = await Post
.with('category', 'tags')
.where('user_id', Number(userId))
.orderBy('published_at', 'desc')
.get()

return Response.json({
ok: true,
posts,
})
}
42 changes: 42 additions & 0 deletions apps/blog-next/app/api/v1/tokens/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import auth, { verifyPassword } from '@holo-js/auth'

import User from '@/server/models/User'

export async function POST(request: Request) {
const formData = await request.formData()
const email = String(formData.get('email') ?? '').trim()
const password = String(formData.get('password') ?? '')

if (!email || !password) {
return Response.json({
ok: false,
message: 'Email and password are required.',
}, { status: 422 })
}

const currentUser = await User.where('email', email).first()
const passwordHash = currentUser?.get('password')
const passwordMatches = typeof passwordHash === 'string'
? await verifyPassword(password, passwordHash)
: false

if (!currentUser || !passwordMatches) {
return Response.json({
ok: false,
message: 'Invalid credentials.',
}, { status: 401 })
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

User enumeration via timing side-channel.

When the email doesn't match a user, verifyPassword is skipped and the response returns almost immediately, whereas a valid email with a wrong password incurs the full hash-verification cost (typically 100–500ms for argon2/bcrypt). Although the error message is intentionally generic, the timing delta lets an attacker enumerate valid accounts. The same pattern appears in the Nuxt and SvelteKit token endpoints.

Recommend always invoking verifyPassword against a stable dummy hash when the user is not found so the response time is independent of account existence.

🛡️ Proposed fix to equalize response timing
-  const currentUser = await User.where('email', email).first()
-  const passwordHash = currentUser?.get('password')
-  const passwordMatches = typeof passwordHash === 'string'
-    ? await verifyPassword(password, passwordHash)
-    : false
+  const currentUser = await User.where('email', email).first()
+  const passwordHash = currentUser?.get('password')
+  // Use a precomputed dummy hash so response time does not leak account existence.
+  const hashToVerify = typeof passwordHash === 'string' ? passwordHash : DUMMY_PASSWORD_HASH
+  const verified = await verifyPassword(password, hashToVerify)
+  const passwordMatches = typeof passwordHash === 'string' && verified

DUMMY_PASSWORD_HASH should be a precomputed argon2/bcrypt hash of an arbitrary string, generated once and stored as a module-level constant (not regenerated per request).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blog-next/app/api/v1/tokens/route.ts` around lines 17 - 28, The login
flow currently skips verifyPassword when User.where('email', email) returns no
user, leaking timing; fix by adding a module-level precomputed
DUMMY_PASSWORD_HASH constant (argon2/bcrypt hash of a fixed password) and always
call verifyPassword(password, actualHashOrDummy) so that verifyPassword is
invoked whether or not currentUser exists — replace the conditional that sets
passwordMatches to instead set const hashToCheck = typeof passwordHash ===
'string' ? passwordHash : DUMMY_PASSWORD_HASH and then await
verifyPassword(password, hashToCheck); keep the generic error response
unchanged.


const token = await auth.tokens.create(currentUser, {
guard: 'api',
name: 'browser-posts-api',
abilities: ['posts.read'],
})

return Response.json({
ok: true,
token: token.plainTextToken,
tokenId: token.id,
abilities: token.abilities,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
1 change: 1 addition & 0 deletions apps/blog-next/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
<nav style={{ maxWidth: '72rem', margin: '0 auto', display: 'flex', gap: '1rem', alignItems: 'center', padding: '1rem 1.5rem', flexWrap: 'wrap' }}>
<Link href="/" style={{ color: '#fff', textDecoration: 'none', fontWeight: 700 }}>blog-next</Link>
<Link href="/posts" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Posts</Link>
<Link href="/api-token-posts" style={{ color: '#cbd5e1', textDecoration: 'none' }}>API Token</Link>
<Link href="/admin" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Admin</Link>
<Link href="/super-admin" style={{ color: '#cbd5e1', textDecoration: 'none' }}>Super Admin</Link>
<AuthProvider initialProvider={currentAuth.provider} initialUser={currentAuth.user}>
Expand Down
4 changes: 4 additions & 0 deletions apps/blog-next/config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export default defineAuthConfig({
driver: 'session',
provider: 'admins',
},
api: {
driver: 'token',
provider: 'users',
},
},
providers: {
users: {
Expand Down
5 changes: 5 additions & 0 deletions apps/blog-next/tests/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { DEFAULT_SESSION_COOKIE_NAME } from '@holo-js/config'
import { assertExampleAppAuthFlow } from '../../../tests/example-app-auth-flow.mjs'
import { assertExampleAppTokenAuthFlow } from '../../../tests/example-app-token-auth-flow.mjs'

const cwd = process.cwd()
const configPath = join(cwd, 'config/app.ts')
Expand Down Expand Up @@ -252,6 +253,10 @@ try {
appName: 'blog-next',
sessionCookieName: DEFAULT_SESSION_COOKIE_NAME,
})
await assertExampleAppTokenAuthFlow({
baseUrl: `http://localhost:${port}`,
expectedTitle: 'Shipping a Real Holo Blog on Next',
})

await writeFile(configPath, originalConfig.replace("name: env('APP_NAME', 'blog-next')", "name: env('APP_NAME', 'blog-next-updated')"))
const updated = await waitForJson(healthUrl, payload => payload.app === 'blog-next-updated')
Expand Down
1 change: 1 addition & 0 deletions apps/blog-nuxt/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ async function logout() {
<nav class="nav">
<NuxtLink to="/" class="brand">blog-nuxt</NuxtLink>
<NuxtLink to="/posts">Posts</NuxtLink>
<NuxtLink to="/api-token-posts">API Token</NuxtLink>
<NuxtLink to="/admin">Admin</NuxtLink>
<NuxtLink to="/super-admin">Super Admin</NuxtLink>
<template v-if="authenticated">
Expand Down
Loading