Skip to content

Commit df03a02

Browse files
committed
feat!: auth improvements, refactors (desc)
Middleware to help check authentication state and refresh session Custom interface for `authState` for provider-agnostic Moved Kinde token to another cookie session key Migrate to `hono-cookie-state` Update deps Apply lint fixes Resolves #52
1 parent b68ddea commit df03a02

51 files changed

Lines changed: 2790 additions & 2650 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend-convex/package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,27 @@
1717
"_deploy": "convex deploy -y"
1818
},
1919
"dependencies": {
20-
"@ai-sdk/anthropic": "^2.0.17",
21-
"@ai-sdk/google": "^2.0.15",
22-
"@ai-sdk/groq": "^2.0.20",
23-
"@ai-sdk/openai": "^2.0.32",
20+
"@ai-sdk/anthropic": "^2.0.23",
21+
"@ai-sdk/google": "^2.0.17",
22+
"@ai-sdk/groq": "^2.0.22",
23+
"@ai-sdk/openai": "^2.0.44",
2424
"@ai-sdk/provider": "^2.0.0",
25-
"@convex-dev/rate-limiter": "^0.2.12",
25+
"@convex-dev/rate-limiter": "^0.2.13",
2626
"@convex-dev/sharded-counter": "^0.1.8",
2727
"@hono/zod-validator": "^0.7.3",
2828
"@openrouter/ai-sdk-provider": "^1.2.0",
29-
"ai": "^5.0.49",
29+
"ai": "^5.0.60",
3030
"convex-helpers": "^0.1.104",
3131
"destr": "^2.0.5",
32-
"hono": "^4.9.8",
33-
"zod": "^4.1.11"
32+
"hono": "^4.9.10",
33+
"zod": "^4.1.12"
3434
},
3535
"devDependencies": {
3636
"@local/common": "workspace:*",
3737
"@local/locales": "workspace:*",
3838
"@local/tsconfig": "workspace:*",
39-
"@namesmt/utils": "^0.5.17",
40-
"convex": "^1.27.3",
39+
"@namesmt/utils": "^0.5.18",
40+
"convex": "^1.27.4",
4141
"kontroll": "^1.1.1",
4242
"vitest": "^3.2.4"
4343
}

apps/backend/package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,29 @@
1717
"deploy": "dotenvx run -f .env.prod.local -f .env.prod -f .env -- sh scripts/deploy.sh"
1818
},
1919
"dependencies": {
20-
"srvx": "^0.8.7"
20+
"srvx": "^0.8.13"
2121
},
2222
"devDependencies": {
2323
"@hono/standard-validator": "^0.1.5",
2424
"@kinde-oss/kinde-typescript-sdk": "^2.13.0",
2525
"@local/common": "workspace:*",
2626
"@local/locales": "workspace:*",
2727
"@local/tsconfig": "workspace:*",
28-
"@namesmt/utils": "^0.5.17",
29-
"@scalar/hono-api-reference": "^0.9.19",
28+
"@namesmt/utils": "^0.5.18",
29+
"@scalar/hono-api-reference": "^0.9.20",
3030
"@vitest/coverage-v8": "^3.2.4",
3131
"arktype": "^2.1.22",
3232
"backend-convex": "workspace:*",
33-
"convex": "^1.27.3",
34-
"grammy": "^1.38.2",
35-
"hono": "^4.9.8",
33+
"convex": "^1.27.4",
34+
"grammy": "^1.38.3",
35+
"hono": "^4.9.10",
3636
"hono-adapter-aws-lambda": "^1.3.3",
37-
"hono-openapi": "^1.0.8",
37+
"hono-cookie-state": "^0.1.2",
38+
"hono-openapi": "^1.1.0",
3839
"hono-sessions": "^0.8.0",
3940
"petite-vue-i18n": "^11.1.12",
4041
"std-env": "^3.9.0",
41-
"tsdown": "^0.15.4",
42+
"tsdown": "^0.15.6",
4243
"vitest": "^3.2.4"
4344
}
4445
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { appFactory } from '#src/helpers/factory.js'
2+
import { getSessionManager } from '#src/helpers/kinde.js'
3+
import { getKindeClient } from '#src/providers/auth/kinde-main.js'
4+
import { DetailedError } from '@namesmt/utils'
5+
import { decode } from 'hono/jwt'
6+
7+
export function keepAuthFresh() {
8+
return appFactory.createMiddleware(async (c, next) => {
9+
const kindeClient = await getKindeClient()
10+
const session = c.get('session')
11+
const sessionManager = getSessionManager(c)
12+
13+
const userAuth = session.data.userAuth
14+
if (!userAuth)
15+
return await next()
16+
17+
const accessToken = await kindeClient.getToken(getSessionManager(c))
18+
19+
// If token will expire in less than 20 minutes, refresh it
20+
if ((decode(accessToken)?.payload?.exp || 0) * 1000 < Date.now() + 1000 * 60 * 20) {
21+
const tokenRefresh = await kindeClient.refreshTokens(sessionManager, true)
22+
23+
session.data.userAuth = {
24+
...userAuth,
25+
tokens: { accessToken: tokenRefresh.access_token },
26+
}
27+
}
28+
29+
await next()
30+
})
31+
}
32+
33+
export type checkAuthParams = {
34+
/**
35+
* If false, will only throw if user is authenticated but token verification fails.
36+
*
37+
* @default true
38+
*/
39+
throwOnUnauthenticated?: boolean
40+
}
41+
export function checkAuth({ throwOnUnauthenticated = true }: checkAuthParams = {}) {
42+
return appFactory.createMiddleware(async (c, next) => {
43+
const session = c.get('session')
44+
const userAuth = session.data.userAuth
45+
if (!userAuth) {
46+
if (throwOnUnauthenticated)
47+
throw new DetailedError('user is not authenticated', { statusCode: 401 })
48+
49+
return await next()
50+
}
51+
52+
// TODO: permission tokens system here
53+
54+
await next()
55+
})
56+
}

apps/backend/src/api/auth/$.routes.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,36 @@
22
* This file contains routes and sample routes for possible APIs usecases with Kinde.
33
*/
44

5-
import type { UserProfileType } from '@local/common/src/types/user'
65
// import type { ClaimTokenType, FlagType } from '@kinde-oss/kinde-typescript-sdk'
76
import { appFactory } from '#src/helpers/factory.js'
87
import { getSessionManager } from '#src/helpers/kinde.js'
98
import { getKindeClient } from '#src/providers/auth/kinde-main.js'
9+
import { objectOmit } from '@local/common/src/utils/general'
1010
import { env } from 'std-env'
1111

1212
export const authRoutesApp = appFactory.createApp()
1313
.get('/health', async (c) => {
1414
return c.text('Good', 200)
1515
})
1616

17+
// This endpoint returns the current auth state
1718
.get('/authState', async (c) => {
18-
const kindeClient = await getKindeClient()
19-
const sessionManager = getSessionManager(c)
19+
const session = c.get('session')
2020

21-
const [profile, token] = await Promise.all([
22-
kindeClient.getUserProfile(sessionManager).catch(() => null) as Promise<UserProfileType>,
23-
kindeClient.getToken(sessionManager).catch(() => null),
24-
])
21+
const userAuth = session.data.userAuth
22+
const tokens = userAuth?.tokens ?? null
2523

26-
return c.json({ profile, token })
24+
return c.json({ userAuth: userAuth ? objectOmit(userAuth, ['tokens']) : null, tokens })
2725
})
2826

2927
.get('/login', async (c) => {
3028
const kindeClient = await getKindeClient()
3129
const org_code = c.req.query('org_code')
30+
const session = c.get('session')
3231

3332
const loginUrl = await kindeClient.login(getSessionManager(c), { org_code })
3433

35-
c.get('session').set('backToPath', c.req.query('path'))
34+
session.data.backToPath = c.req.query('path')
3635

3736
return c.redirect(loginUrl.toString())
3837
})
@@ -48,13 +47,30 @@ export const authRoutesApp = appFactory.createApp()
4847

4948
.get('/callback', async (c) => {
5049
const kindeClient = await getKindeClient()
50+
const session = c.get('session')
51+
const sessionManager = getSessionManager(c)
5152

52-
await kindeClient.handleRedirectToApp(getSessionManager(c), new URL(c.req.url))
53+
await kindeClient.handleRedirectToApp(sessionManager, new URL(c.req.url))
5354

54-
let backToPath = c.get('session').get('backToPath') as string || '/'
55+
let backToPath = session.data.backToPath as string || '/'
5556
if (!backToPath.startsWith('/'))
5657
backToPath = `/${backToPath}`
5758

59+
const kindeProfile = await kindeClient.getUserProfile(sessionManager)
60+
61+
session.data.userAuth = {
62+
id: kindeProfile.id,
63+
avatar: kindeProfile.picture || undefined,
64+
email: kindeProfile.email,
65+
firstName: kindeProfile.given_name,
66+
// @ts-expect-error Kinde SDK is dumb
67+
fullName: kindeProfile.name,
68+
69+
tokens: {
70+
accessToken: await kindeClient.getToken(sessionManager),
71+
},
72+
}
73+
5874
return c.redirect(`${env.FRONTEND_URL!}${backToPath}`)
5975
})
6076

@@ -66,13 +82,14 @@ export const authRoutesApp = appFactory.createApp()
6682
return c.redirect(logoutUrl.toString())
6783
})
6884

69-
// .get('/isAuth', async (c) => {
70-
// const kindeClient = await getKindeClient()
85+
// This endpoint checks if kinde session is authenticated
86+
.get('/isAuth', async (c) => {
87+
const kindeClient = await getKindeClient()
7188

72-
// const isAuthenticated = await kindeClient.isAuthenticated(getSessionManager(c)) // Boolean: true or false
89+
const isAuthenticated = await kindeClient.isAuthenticated(getSessionManager(c))
7390

74-
// return c.json(isAuthenticated)
75-
// })
91+
return c.json(isAuthenticated)
92+
})
7693

7794
// .get('/profile', async (c) => {
7895
// const kindeClient = await getKindeClient()

apps/backend/src/api/auth/$.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { appFactory } from '#src/helpers/factory.js'
22
import { authRoutesApp } from './$.routes'
3+
import { authCheckRoute } from './authCheck'
34

45
export const authApp = appFactory.createApp()
56
.route('', authRoutesApp)
7+
.route('/check', authCheckRoute)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { appFactory } from '#src/helpers/factory.js'
2+
import { checkAuth } from './$.middleware'
3+
4+
// This route should be accessible only when user is authenticated
5+
export const authCheckRoute = appFactory.createApp()
6+
.get(
7+
'',
8+
checkAuth(),
9+
async (c) => {
10+
return c.text('OK')
11+
},
12+
)

apps/backend/src/app.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { errorHandler } from '#src/helpers/error.js'
22
import { appFactory, triggerFactory } from '#src/helpers/factory.js'
3-
import { cookieSession } from '#src/middlewares/session.js'
3+
import { createCookieState } from 'hono-cookie-state'
44
import { cors } from 'hono/cors'
55
import { logger as loggerMiddleware } from 'hono/logger'
66
import { env, isWorkerd } from 'std-env'
@@ -38,8 +38,30 @@ export const app = appFactory.createApp()
3838
credentials: true,
3939
}))
4040

41-
// Session management middleware, configure and see all available managers in `src/middlewares/session.ts`
42-
.use(await cookieSession())
41+
// Main cookie session for the app
42+
.use(createCookieState({
43+
key: 'session',
44+
secret: 'password_at_least_32_characters!',
45+
cookieOptions: {
46+
maxAge: 90 * 60, // 90 mins
47+
sameSite: 'None',
48+
secure: true,
49+
path: '/',
50+
httpOnly: true,
51+
},
52+
}))
53+
// auth vendor's session data
54+
.use(createCookieState({
55+
key: 'authVendorSession',
56+
secret: 'password_at_least_32_characters!',
57+
cookieOptions: {
58+
maxAge: 90 * 60, // 90 mins
59+
sameSite: 'None',
60+
secure: true,
61+
path: '/',
62+
httpOnly: true,
63+
},
64+
}))
4365

4466
// Register API routes
4567
.route('/api', apiApp)

apps/backend/src/helpers/error.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/* eslint-disable no-console */
22
import type { HonoEnv } from '#src/types.js'
3+
import type { DetailedError } from '@namesmt/utils'
34
import type { ErrorHandler } from 'hono'
45
import type { ContentfulStatusCode } from 'hono/utils/http-status'
5-
import { DetailedError } from '@namesmt/utils'
66
import { HTTPException } from 'hono/http-exception'
77

88
export const errorHandler: ErrorHandler<HonoEnv> = (err, c) => {
@@ -26,10 +26,13 @@ export const errorHandler: ErrorHandler<HonoEnv> = (err, c) => {
2626
})
2727
}
2828

29-
if (err instanceof DetailedError) {
29+
// Handling of custom DetailedError (DetailedError can comes from multiple sources (hono's parseResponse, @namesmt/utils))
30+
// So we're using a `.name` check here.
31+
if (err.name === 'DetailedError') {
32+
const _e = err as DetailedError
3033
return _makeErrorRes({
31-
body: { message: err.message, code: err.code ?? err.name, detail: err.detail },
32-
status: err.statusCode ?? 500,
34+
body: { message: _e.message, code: _e.code ?? _e.name, detail: _e.detail },
35+
status: _e.statusCode ?? 500,
3336
})
3437
}
3538

apps/backend/src/helpers/kinde.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,32 @@
1+
import type { HonoEnv } from '#src/types.js'
12
import type { SessionManager } from '@kinde-oss/kinde-typescript-sdk'
23
import type { Context } from 'hono'
3-
import type { Session } from 'hono-sessions'
4+
import type { CookieState } from 'hono-cookie-state'
45
import { HTTPException } from 'hono/http-exception'
56

67
/**
7-
* This is a wrapper on top of hono-sessions for Kinde compatibility
8+
* This is a wrapper on top of `hono-cookie-state` for Kinde compatibility
89
*/
9-
export function toKindeSessionManager(session: Session): SessionManager {
10-
if (!session)
11-
throw new HTTPException(400, { message: 'SessionManager requires a session' })
10+
export function toKindeSessionManager(cs: CookieState<any>): SessionManager {
11+
if (!cs)
12+
throw new HTTPException(400, { message: 'SessionManager requires a CookieState' })
1213

1314
return {
1415
async getSessionItem(key: string) {
15-
return session.get(key)
16+
return cs.data[key]
1617
},
1718
async setSessionItem(key: string, value: unknown) {
18-
session.set(key, value)
19+
cs.data[key] = value
1920
},
2021
async removeSessionItem(key: string) {
21-
delete session.getCache()._data[key]
22+
delete cs.data[key]
2223
},
2324
async destroySession() {
24-
session.deleteSession()
25+
cs.data = {}
2526
},
2627
}
2728
}
2829

29-
/**
30-
* A simple shortcut for `toKindeSessionManager(c.get('session'))`
31-
*/
32-
export function getSessionManager(c: Context) {
33-
return toKindeSessionManager(c.get('session'))
30+
export function getSessionManager(c: Context<HonoEnv>) {
31+
return toKindeSessionManager(c.get('authVendorSession'))
3432
}

apps/backend/src/middlewares/session.ts renamed to apps/backend/src/middlewares/header-session.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,5 @@
11
import type { Context } from 'hono'
2-
import { CookieStore, MemoryStore, Session, sessionMiddleware } from 'hono-sessions'
3-
4-
/**
5-
* `Cookies`-based manager by `hono-sessions`.
6-
*
7-
* Default configuration uses `CookieStore` and just works out of the box.
8-
*/
9-
export async function cookieSession() {
10-
return sessionMiddleware({
11-
store: new CookieStore(),
12-
encryptionKey: 'password_at_least_32_characters!', // Required for CookieStore, recommended for others
13-
expireAfterSeconds: 3600, // Expire session after 1 hour of inactivity
14-
cookieOptions: {
15-
// @ts-expect-error number assign to Date
16-
expires: 3600 + 1800, // Expire cookie after 1 hour 30 minutes of inactivity
17-
sameSite: 'None', // Setting to None to support usecase of different domains for backend and frontend
18-
secure: true, // Enforce HTTPS for cookie, required for sameSite: 'None'
19-
path: '/', // Required for this library to work properly
20-
httpOnly: true, // Recommended to avoid XSS attacks
21-
},
22-
})
23-
}
2+
import { MemoryStore, Session } from 'hono-sessions'
243

254
// TODO: Maybe turn this middleware into a package and fully document it.
265
/**

0 commit comments

Comments
 (0)