Skip to content

Commit c30fa02

Browse files
lane711claude
andauthored
fix(auth): make AuthManager.verifyToken usable from custom Hono routes (#814) (#819)
AuthManager.verifyToken(token) silently fell back to a dev-only placeholder secret when called without the JWT_SECRET argument. Custom routes mounted alongside SonicJS that copy-pasted the docs hit this and rejected every request as "Invalid token". - Add AuthManager.verifyAuthRequest(c) helper that pulls the token from the Authorization header / auth_token cookie and JWT_SECRET from c.env, so custom Hono routes have a one-call equivalent of requireAuth(). - Strengthen verifyToken JSDoc to spell out the secret requirement and point to verifyAuthRequest / requireAuth() as the recommended paths. - Update docs/authentication.md with a "Custom Routes Alongside SonicJS" section walking through the three supported patterns; fix verifyToken examples in routing-middleware.md, architecture.md, and the AI API reference to pass c.env.JWT_SECRET. - Drive-by: rename destructured `is_active` to `_isActive` in the otp-login plugin to clear a pre-existing eslint naming-convention error that was blocking the pre-commit hook. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d1d0e8d commit c30fa02

7 files changed

Lines changed: 182 additions & 33 deletions

File tree

docs/ai/core-package-api-reference.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,14 @@ const hash = await AuthManager.hashPassword('password123')
242242
// Verify password
243243
const valid = await AuthManager.verifyPassword('password123', hash)
244244

245-
// Generate JWT
246-
const token = await AuthManager.generateToken({ userId, email, role })
245+
// Generate JWT (pass JWT_SECRET from c.env)
246+
const token = await AuthManager.generateToken(userId, email, role, c.env.JWT_SECRET)
247247

248-
// Verify JWT
249-
const payload = await AuthManager.verifyToken(token)
248+
// Verify JWT — pass the secret, otherwise the dev fallback is used
249+
const payload = await AuthManager.verifyToken(token, c.env.JWT_SECRET)
250+
251+
// Or, from a Hono handler, do header/cookie/secret extraction in one call:
252+
const payload = await AuthManager.verifyAuthRequest(c)
250253
```
251254

252255
### Logging Middleware

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,9 +390,9 @@ export const requireAuth = () => {
390390
}
391391
}
392392

393-
// Verify token if not cached
393+
// Verify token if not cached (pass JWT_SECRET from the env binding)
394394
if (!payload) {
395-
payload = await AuthManager.verifyToken(token)
395+
payload = await AuthManager.verifyToken(token, c.env?.JWT_SECRET)
396396

397397
// Cache for 5 minutes
398398
if (payload && kv) {

docs/authentication.md

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -177,39 +177,35 @@ fresh one via `POST /auth/refresh`.
177177

178178
### Token Verification
179179

180+
The `JWT_SECRET` lives on the Cloudflare Workers binding (`c.env.JWT_SECRET`), so
181+
you must thread the secret through when verifying tokens. The easiest way from a
182+
Hono handler is `AuthManager.verifyAuthRequest(c)`, which extracts the token
183+
from the `Authorization` header (or `auth_token` cookie) and pulls the secret
184+
from `c.env` for you:
185+
180186
```typescript
181-
// Verify and decode token
182-
const payload = await AuthManager.verifyToken(token)
187+
// Inside a custom Hono route handler
188+
const payload = await AuthManager.verifyAuthRequest(c)
183189

184190
if (!payload) {
185-
// Token invalid or expired
186191
return c.json({ error: 'Invalid or expired token' }, 401)
187192
}
188193

189-
// Token is valid, payload contains user info
190194
console.log(payload.userId, payload.email, payload.role)
191195
```
192196

193-
**Implementation:**
197+
If you already have the raw token, call `verifyToken` directly and pass the
198+
secret yourself:
194199

195200
```typescript
196-
static async verifyToken(token: string): Promise<JWTPayload | null> {
197-
try {
198-
const payload = await verify(token, JWT_SECRET, 'HS256') as JWTPayload
199-
200-
// Check if token is expired
201-
if (payload.exp < Math.floor(Date.now() / 1000)) {
202-
return null
203-
}
204-
205-
return payload
206-
} catch (error) {
207-
console.error('Token verification failed:', error)
208-
return null
209-
}
210-
}
201+
const payload = await AuthManager.verifyToken(token, c.env.JWT_SECRET)
211202
```
212203

204+
> **Heads up:** `AuthManager.verifyToken(token)` (no secret argument) falls
205+
> back to a development-only placeholder secret. In production this will
206+
> silently fail to verify any real token. Always pass `c.env.JWT_SECRET`, or
207+
> use `verifyAuthRequest(c)` / the `requireAuth()` middleware.
208+
213209
### Token Configuration
214210

215211
```typescript
@@ -256,9 +252,9 @@ export const requireAuth = () => {
256252
}
257253
}
258254

259-
// If not cached, verify token
255+
// If not cached, verify token (passing the JWT_SECRET binding)
260256
if (!payload) {
261-
payload = await AuthManager.verifyToken(token)
257+
payload = await AuthManager.verifyToken(token, c.env?.JWT_SECRET)
262258

263259
// Cache the verified payload for 5 minutes
264260
if (payload && kv) {
@@ -1094,6 +1090,59 @@ app.get('/content/:id',
10941090
)
10951091
```
10961092

1093+
### Custom Routes Alongside SonicJS
1094+
1095+
When you mount your own Hono routes next to a SonicJS app, you can authenticate
1096+
requests with the same JWT that SonicJS issues. Three options, ordered by
1097+
preference:
1098+
1099+
**1. Use `requireAuth()` middleware** (recommendedmatches what SonicJS uses
1100+
internally, including the KV verification cache):
1101+
1102+
```typescript
1103+
import { Hono } from 'hono'
1104+
import { requireAuth, createSonicJSApp } from '@sonicjs-cms/core'
1105+
1106+
const app = new Hono()
1107+
const adminRoutes = new Hono()
1108+
1109+
adminRoutes.use('*', requireAuth())
1110+
adminRoutes.get('/stats', (c) => {
1111+
const user = c.get('user') // { userId, email, role, ... }
1112+
return c.json({ user })
1113+
})
1114+
1115+
app.route('/api/admin', adminRoutes)
1116+
app.route('/', createSonicJSApp(config))
1117+
```
1118+
1119+
**2. Use `AuthManager.verifyAuthRequest(c)`** when you need custom error
1120+
handling but still want the helper to extract the token + secret for you:
1121+
1122+
```typescript
1123+
import { AuthManager } from '@sonicjs-cms/core'
1124+
1125+
adminRoutes.use('*', async (c, next) => {
1126+
const payload = await AuthManager.verifyAuthRequest(c)
1127+
if (!payload) return c.json({ error: 'Invalid token' }, 401)
1128+
if (payload.role !== 'admin') return c.json({ error: 'Forbidden' }, 403)
1129+
c.set('user', payload)
1130+
await next()
1131+
})
1132+
```
1133+
1134+
**3. Call `AuthManager.verifyToken(token, secret)` directly** when you've
1135+
already extracted the token yourself. Always pass `c.env.JWT_SECRET`:
1136+
1137+
```typescript
1138+
const token = c.req.header('Authorization')?.replace('Bearer ', '')
1139+
const payload = await AuthManager.verifyToken(token, c.env.JWT_SECRET)
1140+
```
1141+
1142+
> **Don't call `AuthManager.verifyToken(token)` without a secret.** It falls
1143+
> back to a development-only placeholder, so any token signed with your real
1144+
> `JWT_SECRET` will silently fail verification.
1145+
10971146
### Custom Authorization Logic
10981147

10991148
```typescript

docs/routing-middleware.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,22 @@ The authentication system uses JWT tokens stored in HTTP-only cookies.
8787
```typescript
8888
import { AuthManager } from '../middleware/auth'
8989

90-
// Generate JWT token
90+
// Generate JWT token (pass JWT_SECRET from c.env)
9191
const token = await AuthManager.generateToken(
9292
userId,
9393
email,
94-
role
94+
role,
95+
c.env.JWT_SECRET
9596
)
9697

97-
// Verify JWT token
98-
const payload = await AuthManager.verifyToken(token)
98+
// Verify JWT token — always pass the secret from c.env
99+
const payload = await AuthManager.verifyToken(token, c.env.JWT_SECRET)
99100
// Returns: { userId, email, role, exp, iat } or null
100101

102+
// Or, from inside a Hono handler, let the helper extract the token
103+
// (Authorization header / auth_token cookie) and secret for you:
104+
const payload = await AuthManager.verifyAuthRequest(c)
105+
101106
// Hash password
102107
const hash = await AuthManager.hashPassword(password)
103108

packages/core/src/__tests__/middleware/auth.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,70 @@ describe('AuthManager', () => {
132132
})
133133
})
134134

135+
describe('verifyAuthRequest', () => {
136+
const buildContext = (opts: {
137+
authHeader?: string
138+
cookieHeader?: string
139+
env?: Record<string, any>
140+
}) => {
141+
const headers = new Headers()
142+
if (opts.authHeader) headers.set('Authorization', opts.authHeader)
143+
if (opts.cookieHeader) headers.set('Cookie', opts.cookieHeader)
144+
return {
145+
req: {
146+
header: (name: string) => headers.get(name) ?? undefined,
147+
raw: { headers }
148+
},
149+
env: opts.env ?? {}
150+
} as unknown as Context
151+
}
152+
153+
it('verifies a token from the Authorization header', async () => {
154+
const secret = 'request-helper-secret'
155+
const token = await AuthManager.generateToken('u-1', 'a@b.c', 'admin', secret, 60)
156+
157+
const c = buildContext({
158+
authHeader: `Bearer ${token}`,
159+
env: { JWT_SECRET: secret }
160+
})
161+
162+
const payload = await AuthManager.verifyAuthRequest(c)
163+
expect(payload?.userId).toBe('u-1')
164+
expect(payload?.email).toBe('a@b.c')
165+
expect(payload?.role).toBe('admin')
166+
})
167+
168+
it('falls back to the auth_token cookie when no Authorization header is present', async () => {
169+
const secret = 'request-helper-secret'
170+
const token = await AuthManager.generateToken('u-2', 'c@d.e', 'editor', secret, 60)
171+
172+
const c = buildContext({
173+
cookieHeader: `auth_token=${token}`,
174+
env: { JWT_SECRET: secret }
175+
})
176+
177+
const payload = await AuthManager.verifyAuthRequest(c)
178+
expect(payload?.userId).toBe('u-2')
179+
})
180+
181+
it('returns null when no token is provided', async () => {
182+
const c = buildContext({ env: { JWT_SECRET: 'whatever' } })
183+
const payload = await AuthManager.verifyAuthRequest(c)
184+
expect(payload).toBeNull()
185+
})
186+
187+
it('returns null when token is signed with a different secret than c.env.JWT_SECRET', async () => {
188+
const token = await AuthManager.generateToken('u-3', 'e@f.g', 'admin', 'real-secret', 60)
189+
const c = buildContext({
190+
authHeader: `Bearer ${token}`,
191+
env: { JWT_SECRET: 'wrong-secret' }
192+
})
193+
194+
const payload = await AuthManager.verifyAuthRequest(c)
195+
expect(payload).toBeNull()
196+
})
197+
})
198+
135199
describe('getJwtExpirySeconds', () => {
136200
it('defaults to 30 days when env is empty', () => {
137201
expect(getJwtExpirySeconds({})).toBe(60 * 60 * 24 * 30)

packages/core/src/middleware/auth.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ export class AuthManager {
196196
/**
197197
* Verify a token's signature and expiration.
198198
*
199+
* IMPORTANT: pass the `JWT_SECRET` binding (e.g. `c.env.JWT_SECRET`) as the
200+
* `secret` argument. If omitted, this falls back to a development-only
201+
* placeholder secret — tokens signed with the real `JWT_SECRET` will then
202+
* silently fail verification. From inside a Hono handler prefer
203+
* `AuthManager.verifyAuthRequest(c)`, which handles header/cookie extraction
204+
* and pulls the secret from `c.env` for you.
205+
*
199206
* If `graceSeconds` > 0, tokens whose `exp` is within the grace window
200207
* (i.e. expired by no more than `graceSeconds`) are still returned. This
201208
* supports a sliding-session refresh endpoint that accepts recently-expired
@@ -243,6 +250,27 @@ export class AuthManager {
243250
}
244251
}
245252

253+
/**
254+
* Verify the JWT on an incoming Hono request using the `JWT_SECRET`
255+
* binding from `c.env`. Reads the token from the `Authorization: Bearer …`
256+
* header first, then falls back to the `auth_token` cookie. Returns the
257+
* decoded payload, or null when the token is missing, malformed, expired,
258+
* or signed with a different secret.
259+
*
260+
* Use this from custom Hono routes mounted alongside SonicJS — it
261+
* resolves the secret the same way `requireAuth()` does, without forcing
262+
* the caller to plumb it through manually.
263+
*/
264+
static async verifyAuthRequest(c: Context): Promise<JWTPayload | null> {
265+
let token = c.req.header('Authorization')?.replace('Bearer ', '')
266+
if (!token) {
267+
token = getCookie(c, 'auth_token')
268+
}
269+
if (!token) return null
270+
const secret = (c.env as any)?.JWT_SECRET
271+
return await AuthManager.verifyToken(token, secret)
272+
}
273+
246274
static async hashPassword(password: string): Promise<string> {
247275
const iterations = 100000
248276
const salt = new Uint8Array(16)

packages/core/src/plugins/core-plugins/otp-login-plugin/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ export function createOTPLoginPlugin(): Plugin {
333333
})
334334

335335
const customData = await getCustomData(db, user.id)
336-
const { is_active, ...publicUser } = user
336+
const { is_active: _isActive, ...publicUser } = user
337337

338338
return c.json({
339339
success: true,

0 commit comments

Comments
 (0)