Compare commits
93 Commits
644f7b320a
...
2f0016819d
Author | SHA1 | Date |
---|---|---|
|
2f0016819d | |
|
0dc8b719b4 | |
|
01cd896e9b | |
|
928f8cd5b8 | |
|
1765a9a3aa | |
|
247f7705b3 | |
|
8ed99d9d79 | |
|
b9fa57530a | |
|
a756d2235b | |
|
237bff1b82 | |
|
05e09f6a4b | |
|
595fa28485 | |
|
3e13eefc67 | |
|
95b87e5b5a | |
|
b4ceb3a82c | |
|
e4c5c3d07f | |
|
95f746f964 | |
|
17df8a47c8 | |
|
dc917efec0 | |
|
59559961bb | |
|
b09d7bbc2c | |
|
509ead8934 | |
|
741e9d49ff | |
|
7e2652d297 | |
|
d61e8a66ea | |
|
3b5eb36b4f | |
|
6edd7bc1ce | |
|
226f4e7b9b | |
|
77fce3c040 | |
|
8810eccff2 | |
|
db5207c509 | |
|
c9dfd2b5a3 | |
|
e2e4f6aa52 | |
|
5f3a3fa29c | |
|
76f00959c6 | |
|
70834f9f70 | |
|
7cba581041 | |
|
21afe41dec | |
|
5b1e8ae2e5 | |
|
e31cd008aa | |
|
7498fbcff1 | |
|
4db6cce55d | |
|
1924c630a8 | |
|
2e6c051b11 | |
|
369680c298 | |
|
ddba7cf343 | |
|
8557f3bc0b | |
|
008ce5eb83 | |
|
b1d32f5783 | |
|
5a65ef92b0 | |
|
a2c278515f | |
|
72b20df1c8 | |
|
34c71fead3 | |
|
b73045462e | |
|
cb6c76fef7 | |
|
3529d32b17 | |
|
5f4572f5b8 | |
|
978da3e14b | |
|
2d91b60742 | |
|
fcce865c87 | |
|
9e77615d91 | |
|
abd52ce669 | |
|
6a7e42d4ee | |
|
c1adb69a5c | |
|
a5b900e503 | |
|
66c265f7ea | |
|
7a8b23d8c8 | |
|
3b9448b870 | |
|
23b4cdd414 | |
|
4f2744b181 | |
|
8f97fdf83e | |
|
15971bde72 | |
|
9e23349777 | |
|
d65cc146f9 | |
|
4b66525ad3 | |
|
650cd0409b | |
|
43794beaf5 | |
|
1b29fd1c35 | |
|
9da50dcc8c | |
|
59a9a2747e | |
|
95dd8e74ad | |
|
64154467f0 | |
|
35031cb9f1 | |
|
fc20f9c6f4 | |
|
23fa14c596 | |
|
4a1038ee66 | |
|
9d7a29d178 | |
|
86cb7db506 | |
|
e2726fd622 | |
|
990c8a5047 | |
|
5ffb59fb53 | |
|
2c16357028 | |
|
76665d716e |
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@hono/oauth-providers': minor
|
||||
---
|
||||
|
||||
The PR adds Microsoft Entra (AzureAD) to the list of supported 3rd-party OAuth providers.
|
|
@ -67,7 +67,7 @@ export function ajvValidator<
|
|||
T,
|
||||
Target extends keyof ValidationTargets,
|
||||
E extends Env = Env,
|
||||
P extends string = string
|
||||
P extends string = string,
|
||||
>(
|
||||
target: Target,
|
||||
schema: JSONSchemaType<T>,
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
# @hono/arktype-validator
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1137](https://github.com/honojs/middleware/pull/1137) [`01cd896e9b3c6a00c3c16ed59e0c3d20f5983918`](https://github.com/honojs/middleware/commit/01cd896e9b3c6a00c3c16ed59e0c3d20f5983918) Thanks [@MonsterDeveloper](https://github.com/MonsterDeveloper)! - Don't return restricted data fields on error responses
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Major Changes
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@hono/arktype-validator",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "ArkType validator middleware",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
@ -35,6 +35,17 @@ describe('Basic', () => {
|
|||
}
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/headers',
|
||||
arktypeValidator(
|
||||
'header',
|
||||
type({
|
||||
'User-Agent': 'string',
|
||||
})
|
||||
),
|
||||
(c) => c.json({ success: true, userAgent: c.header('User-Agent') })
|
||||
)
|
||||
|
||||
type Actual = ExtractSchema<typeof route>
|
||||
type Expected = {
|
||||
'/author': {
|
||||
|
@ -98,6 +109,22 @@ describe('Basic', () => {
|
|||
const data = (await res.json()) as { success: boolean }
|
||||
expect(data['success']).toBe(false)
|
||||
})
|
||||
|
||||
it("doesn't return cookies after headers validation", async () => {
|
||||
const req = new Request('http://localhost/headers', {
|
||||
headers: {
|
||||
'User-Agent': 'invalid',
|
||||
Cookie: 'SECRET=123',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(400)
|
||||
const data = (await res.json()) as { succcess: false; errors: type.errors }
|
||||
expect(data.errors).toHaveLength(1)
|
||||
expect(data.errors[0].data).not.toHaveProperty('cookie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('With Hook', () => {
|
||||
|
|
|
@ -10,6 +10,10 @@ export type Hook<T, E extends Env, P extends string, O = {}> = (
|
|||
|
||||
type HasUndefined<T> = undefined extends T ? true : false
|
||||
|
||||
const RESTRICTED_DATA_FIELDS = {
|
||||
header: ['cookie'],
|
||||
}
|
||||
|
||||
export const arktypeValidator = <
|
||||
T extends Type,
|
||||
Target extends keyof ValidationTargets,
|
||||
|
@ -23,7 +27,7 @@ export const arktypeValidator = <
|
|||
} = {
|
||||
in: HasUndefined<I> extends true ? { [K in Target]?: I } : { [K in Target]: I }
|
||||
out: { [K in Target]: O }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
|
@ -54,7 +58,31 @@ export const arktypeValidator = <
|
|||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
errors: out,
|
||||
errors:
|
||||
target in RESTRICTED_DATA_FIELDS
|
||||
? out.map((error) => {
|
||||
const restrictedFields =
|
||||
RESTRICTED_DATA_FIELDS[target as keyof typeof RESTRICTED_DATA_FIELDS] || []
|
||||
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
typeof error.data === 'object' &&
|
||||
error.data !== null &&
|
||||
!Array.isArray(error.data)
|
||||
) {
|
||||
const dataCopy = { ...(error.data as Record<string, unknown>) }
|
||||
for (const field of restrictedFields) {
|
||||
delete dataCopy[field]
|
||||
}
|
||||
|
||||
error.data = dataCopy
|
||||
}
|
||||
|
||||
return error
|
||||
})
|
||||
: out,
|
||||
},
|
||||
400
|
||||
)
|
||||
|
|
|
@ -225,7 +225,7 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||
}
|
||||
return updatedSession
|
||||
},
|
||||
} as SessionContextValue),
|
||||
}) as SessionContextValue,
|
||||
[session, loading, setSession]
|
||||
)
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ type Hook<
|
|||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
O = object
|
||||
O = object,
|
||||
> = (
|
||||
result: ({ success: true } | { success: false; errors: ValidationError[] }) & {
|
||||
data: T
|
||||
|
@ -119,19 +119,19 @@ export const classValidator = <
|
|||
[K in Target]?: K extends 'json'
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
}
|
||||
: {
|
||||
[K in Target]: K extends 'json'
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
}
|
||||
out: { [K in Target]: Output }
|
||||
},
|
||||
V extends I = I
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
dataType: T,
|
||||
|
|
|
@ -32,7 +32,7 @@ export const conformValidator = <
|
|||
form: { [K in keyof In]: FormTargetValue }
|
||||
}
|
||||
out: { form: GetSuccessSubmission<Out> }
|
||||
}
|
||||
},
|
||||
>(
|
||||
parse: F,
|
||||
hook?: Hook<F, E, P>
|
||||
|
|
|
@ -19,18 +19,18 @@ export const effectValidator = <
|
|||
[K in Target]?: K extends 'json'
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
}
|
||||
: {
|
||||
[K in Target]: K extends 'json'
|
||||
? In
|
||||
: HasUndefined<keyof ValidationTargets[K]> extends true
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
? { [K2 in keyof In]?: ValidationTargets[K][K2] }
|
||||
: { [K2 in keyof In]: ValidationTargets[K][K2] }
|
||||
}
|
||||
out: { [K in Target]: Out }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: S.Schema<Type, Encoded, never>
|
||||
|
|
|
@ -35,7 +35,7 @@ export interface Emitter<EPMap extends EventPayloadMap> {
|
|||
export const defineHandler = <
|
||||
EPMap extends EventPayloadMap,
|
||||
Key extends keyof EPMap,
|
||||
E extends Env = Env
|
||||
E extends Env = Env,
|
||||
>(
|
||||
handler: EventHandler<EPMap[Key], E>
|
||||
): EventHandler<EPMap[Key], E> => {
|
||||
|
|
|
@ -1,5 +1,17 @@
|
|||
# @hono/node-ws
|
||||
|
||||
## 1.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1141](https://github.com/honojs/middleware/pull/1141) [`1765a9a3aa1ab6aa59b0efd2d161b7bfaa565ef7`](https://github.com/honojs/middleware/commit/1765a9a3aa1ab6aa59b0efd2d161b7bfaa565ef7) Thanks [@nakasyou](https://github.com/nakasyou)! - Make it uncrashable
|
||||
|
||||
## 1.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#1138](https://github.com/honojs/middleware/pull/1138) [`237bff1b82f2c0adfcd015dc97538b36cfb5d418`](https://github.com/honojs/middleware/commit/237bff1b82f2c0adfcd015dc97538b36cfb5d418) Thanks [@leia-uwu](https://github.com/leia-uwu)! - Fix missing code and reason on `CloseEvent`
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@hono/node-ws",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"description": "WebSocket helper for Node.js",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
@ -170,18 +170,21 @@ describe('WebSocket helper', () => {
|
|||
})
|
||||
|
||||
it('CloseEvent should be executed without crash', async () => {
|
||||
const testCode = 3001
|
||||
const testReason = 'Test!'
|
||||
app.get(
|
||||
'/',
|
||||
upgradeWebSocket(() => ({
|
||||
onClose() {
|
||||
// doing some stuff here
|
||||
onClose(event) {
|
||||
expect(event.code).toBe(testCode)
|
||||
expect(event.reason).toBe(testReason)
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const ws = new WebSocket('ws://localhost:3030/')
|
||||
await new Promise<void>((resolve) => ws.on('open', resolve))
|
||||
ws.close()
|
||||
ws.close(testCode, testReason)
|
||||
})
|
||||
|
||||
it('Should be able to send and receive binary content with good length', async () => {
|
||||
|
@ -211,6 +214,26 @@ describe('WebSocket helper', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('Should onError works well', async () => {
|
||||
const mainPromise = new Promise<unknown>((resolve) =>
|
||||
app.get(
|
||||
'/',
|
||||
upgradeWebSocket(
|
||||
() => {
|
||||
throw 0
|
||||
},
|
||||
{
|
||||
onError(err) {
|
||||
resolve(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
const ws = new WebSocket('ws://localhost:3030/')
|
||||
expect(await mainPromise).toBe(0)
|
||||
})
|
||||
|
||||
describe('Types', () => {
|
||||
it('Should not throw a type error with an app with Variables generics', () => {
|
||||
const app = new Hono<{
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { Hono } from 'hono'
|
||||
import type { UpgradeWebSocket, WSContext } from 'hono/ws'
|
||||
import type { UpgradeWebSocket, WSContext, WSEvents } from 'hono/ws'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { IncomingMessage } from 'http'
|
||||
|
@ -9,7 +9,12 @@ import type { Duplex } from 'node:stream'
|
|||
import { CloseEvent } from './events'
|
||||
|
||||
export interface NodeWebSocket {
|
||||
upgradeWebSocket: UpgradeWebSocket<WebSocket>
|
||||
upgradeWebSocket: UpgradeWebSocket<
|
||||
WebSocket,
|
||||
{
|
||||
onError: (err: unknown) => void
|
||||
}
|
||||
>
|
||||
injectWebSocket(server: Server | Http2Server | Http2SecureServer): void
|
||||
}
|
||||
export interface NodeWebSocketInit {
|
||||
|
@ -80,7 +85,7 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
|||
})
|
||||
})
|
||||
},
|
||||
upgradeWebSocket: (createEvents) =>
|
||||
upgradeWebSocket: (createEvents, options) =>
|
||||
async function upgradeWebSocket(c, next) {
|
||||
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
|
||||
// Not websocket
|
||||
|
@ -91,7 +96,14 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
|||
const response = new Response()
|
||||
;(async () => {
|
||||
const ws = await nodeUpgradeWebSocket(c.env.incoming, response)
|
||||
const events = await createEvents(c)
|
||||
let events: WSEvents<WebSocket>
|
||||
try {
|
||||
events = await createEvents(c)
|
||||
} catch (e) {
|
||||
;(options?.onError ?? console.error)(e)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
const ctx: WSContext<WebSocket> = {
|
||||
binaryType: 'arraybuffer',
|
||||
|
@ -110,33 +122,49 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
|||
},
|
||||
url: new URL(c.req.url),
|
||||
}
|
||||
events.onOpen?.(new Event('open'), ctx)
|
||||
try {
|
||||
events?.onOpen?.(new Event('open'), ctx)
|
||||
} catch (e) {
|
||||
;(options?.onError ?? console.error)(e)
|
||||
}
|
||||
ws.on('message', (data, isBinary) => {
|
||||
const datas = Array.isArray(data) ? data : [data]
|
||||
for (const data of datas) {
|
||||
events.onMessage?.(
|
||||
new MessageEvent('message', {
|
||||
data: isBinary
|
||||
? data instanceof ArrayBuffer
|
||||
? data
|
||||
: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
||||
: data.toString('utf-8'),
|
||||
try {
|
||||
events?.onMessage?.(
|
||||
new MessageEvent('message', {
|
||||
data: isBinary
|
||||
? data instanceof ArrayBuffer
|
||||
? data
|
||||
: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
||||
: data.toString('utf-8'),
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
} catch (e) {
|
||||
;(options?.onError ?? console.error)(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
ws.on('close', (code, reason) => {
|
||||
try {
|
||||
events?.onClose?.(new CloseEvent('close', { code, reason: reason.toString() }), ctx)
|
||||
} catch (e) {
|
||||
;(options?.onError ?? console.error)(e)
|
||||
}
|
||||
})
|
||||
ws.on('error', (error) => {
|
||||
try {
|
||||
events?.onError?.(
|
||||
new ErrorEvent('error', {
|
||||
error: error,
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
} catch (e) {
|
||||
;(options?.onError ?? console.error)(e)
|
||||
}
|
||||
})
|
||||
ws.on('close', () => {
|
||||
events.onClose?.(new CloseEvent('close'), ctx)
|
||||
})
|
||||
ws.on('error', (error) => {
|
||||
events.onError?.(
|
||||
new ErrorEvent('error', {
|
||||
error: error,
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
})
|
||||
})()
|
||||
|
||||
return response
|
||||
|
|
|
@ -1087,6 +1087,128 @@ The validation endpoint helps your application detect when tokens become invalid
|
|||
|
||||
> For security and compliance, make sure to implement regular token validation in your application. If a token becomes invalid, promptly sign out the user and terminate their OAuth session.
|
||||
|
||||
### MSEntra
|
||||
|
||||
```ts
|
||||
import { Hono } from 'hono'
|
||||
import { msentraAuth } from '@hono/oauth-providers/msentra'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use(
|
||||
'/msentra',
|
||||
msentraAuth({
|
||||
client_id: process.env.MSENTRA_ID,
|
||||
client_secret: process.env.MSENTRA_SECRET,
|
||||
tenant_id: process.env.MSENTRA_TENANT_ID
|
||||
scope: [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'https;//graph.microsoft.com/.default',
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- `client_id`:
|
||||
- Type: `string`.
|
||||
- `Required`.
|
||||
- Your app client Id. You can find this in your Azure Portal.
|
||||
- `client_secret`:
|
||||
- Type: `string`.
|
||||
- `Required`.
|
||||
- Your app client secret. You can find this in your Azure Portal.
|
||||
> ⚠️ Do **not** share your **client secret** to ensure the security of your app.
|
||||
- `tenant_id`:
|
||||
- Type: `string`
|
||||
- `Required`.
|
||||
- Your Microsoft Tenant's Id. You can find this in your Azure Portal.
|
||||
- `scope`:
|
||||
- Type: `string[]`.
|
||||
- `Required`.
|
||||
- Set of **permissions** to request the user's authorization to access your app for retrieving
|
||||
user information and performing actions on their behalf.
|
||||
|
||||
#### Authentication Flow
|
||||
|
||||
After the completion of the MSEntra OAuth flow, essential data has been prepared for use in the
|
||||
subsequent steps that your app needs to take.
|
||||
|
||||
`msentraAuth` method provides 4 set key data:
|
||||
|
||||
- `token`:
|
||||
- Access token to make requests to the MSEntra API for retrieving user information and
|
||||
performing actions on their behalf.
|
||||
- Type:
|
||||
```
|
||||
{
|
||||
token: string
|
||||
expires_in: number
|
||||
refresh_token: string
|
||||
}
|
||||
```
|
||||
- `granted-scopes`:
|
||||
- Scopes for which the user has granted permissions.
|
||||
- Type: `string[]`.
|
||||
- `user-msentra`:
|
||||
- User basic info retrieved from MSEntra
|
||||
- Type:
|
||||
```
|
||||
{
|
||||
businessPhones: string[],
|
||||
displayName: string
|
||||
givenName: string
|
||||
jobTitle: string
|
||||
mail: string
|
||||
mobilePhone: string
|
||||
officeLocation: string
|
||||
surname: string
|
||||
userPrincipalName: string
|
||||
id: string
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request
|
||||
> handler.
|
||||
|
||||
```ts
|
||||
app.get('/msentra', (c) => {
|
||||
const token = c.get('token')
|
||||
const grantedScopes = c.get('granted-scopes')
|
||||
const user = c.get('user-msentra')
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
grantedScopes,
|
||||
user,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### Refresh Token
|
||||
|
||||
Once the user token expires you can refresh their token without the need to prompt the user again
|
||||
for access. In such scenario, you can utilize the `refreshToken` method, which accepts the
|
||||
`client_id`, `client_secret`, `tenant_id`, and `refresh_token` as parameters.
|
||||
|
||||
> [!NOTE]
|
||||
> The `refresh_token` can be used once. Once the token is refreshed MSEntra gives you a new
|
||||
> `refresh_token` along with the new token.
|
||||
|
||||
```ts
|
||||
import { msentraAuth, refreshToken } from '@hono/oauth-providers/msentra'
|
||||
|
||||
app.get('/msentra/refresh', (c, next) => {
|
||||
const newTokens = await refreshToken({ client_id, client_secret, tenant_id, refresh_token })
|
||||
})
|
||||
```
|
||||
|
||||
## Advance Usage
|
||||
|
||||
### Customize `redirect_uri`
|
||||
|
|
|
@ -10,6 +10,11 @@ import type {
|
|||
import type { GitHubErrorResponse, GitHubTokenResponse } from './src/providers/github'
|
||||
import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from './src/providers/google'
|
||||
import type { LinkedInErrorResponse, LinkedInTokenResponse } from './src/providers/linkedin'
|
||||
import type {
|
||||
MSEntraErrorResponse,
|
||||
MSEntraTokenResponse,
|
||||
MSEntraUser,
|
||||
} from './src/providers/msentra'
|
||||
import type {
|
||||
TwitchErrorResponse,
|
||||
TwitchTokenResponse,
|
||||
|
@ -206,6 +211,31 @@ export const handlers = [
|
|||
return HttpResponse.json(twitchValidateError, { status: 401 })
|
||||
}
|
||||
),
|
||||
|
||||
// MSEntra
|
||||
http.post(
|
||||
'https://login.microsoft.com/fake-tenant-id/oauth2/v2.0/token',
|
||||
async ({
|
||||
request,
|
||||
}): Promise<StrictResponse<Partial<MSEntraTokenResponse> | MSEntraErrorResponse>> => {
|
||||
const body = new URLSearchParams(await request.text())
|
||||
if (body.get('code') === dummyCode || body.get('refresh_token') === msentraRefreshToken) {
|
||||
return HttpResponse.json(msentraToken)
|
||||
}
|
||||
return HttpResponse.json(msentraCodeError)
|
||||
}
|
||||
),
|
||||
http.get(
|
||||
'https://graph.microsoft.com/v1.0/me',
|
||||
async ({ request }): Promise<StrictResponse<Partial<MSEntraUser> | MSEntraErrorResponse>> => {
|
||||
const authorization = request.headers.get('authorization')
|
||||
|
||||
if (authorization === `Bearer ${msentraToken.access_token}`) {
|
||||
return HttpResponse.json(msentraUser)
|
||||
}
|
||||
return HttpResponse.json(msentraCodeError)
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
|
||||
|
@ -558,3 +588,28 @@ export const twitchValidateError = {
|
|||
status: 401,
|
||||
message: 'invalid access token',
|
||||
}
|
||||
|
||||
export const msentraRefreshToken = 'paofniueawnbfisdjkaierlufjkdnsj'
|
||||
export const msentraToken = {
|
||||
...dummyToken,
|
||||
refresh_token: msentraRefreshToken,
|
||||
}
|
||||
export const msentraUser = {
|
||||
'@odata.context': 'https://graph.microsoft.com/v1.0/$metadata#users/$entity',
|
||||
businessPhones: ['111-111-1111'],
|
||||
displayName: 'Test User',
|
||||
givenName: 'Test',
|
||||
jobTitle: 'Developer',
|
||||
mail: 'example@email.com',
|
||||
mobilePhone: '111-111-1111',
|
||||
officeLocation: 'es-419',
|
||||
preferredLanguage: null,
|
||||
surname: 'User',
|
||||
userPrincipalName: 'example@email.com',
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
}
|
||||
export const msentraCodeError = {
|
||||
error: 'invalid_grant',
|
||||
error_description: 'AADSTS1234567: Invalid request.',
|
||||
error_codes: [1234567],
|
||||
}
|
||||
|
|
|
@ -27,6 +27,30 @@
|
|||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./*": {
|
||||
"import": {
|
||||
"types": "./dist/providers/*/index.d.ts",
|
||||
"default": "./dist/providers/*/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/providers/*/index.d.cts",
|
||||
"default": "./dist/providers/*/index.cjs"
|
||||
}
|
||||
},
|
||||
"./msentra": {
|
||||
"import": {
|
||||
"types": "./dist/providers/msentra/index.d.mts",
|
||||
"default": "./dist/providers/msentra/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/providers/msentra/index.d.ts",
|
||||
"default": "./dist/providers/msentra/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./*": {
|
||||
"import": {
|
||||
"types": "./dist/providers/*/index.d.ts",
|
||||
|
@ -60,6 +84,9 @@
|
|||
],
|
||||
"twitch": [
|
||||
"./dist/providers/twitch/index.d.ts"
|
||||
],
|
||||
"msentra": [
|
||||
"./dist/providers/msentra/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -19,6 +19,10 @@ import {
|
|||
linkedInCodeError,
|
||||
linkedInToken,
|
||||
linkedInUser,
|
||||
msentraCodeError,
|
||||
msentraRefreshToken,
|
||||
msentraToken,
|
||||
msentraUser,
|
||||
xCodeError,
|
||||
xRefreshToken,
|
||||
xRefreshTokenError,
|
||||
|
@ -48,6 +52,8 @@ import { googleAuth } from './providers/google'
|
|||
import type { GoogleUser } from './providers/google'
|
||||
import { linkedinAuth } from './providers/linkedin'
|
||||
import type { LinkedInUser } from './providers/linkedin'
|
||||
import type { MSEntraUser } from './providers/msentra'
|
||||
import { msentraAuth, refreshToken as msentraRefresh } from './providers/msentra'
|
||||
import type { TwitchUser } from './providers/twitch'
|
||||
import {
|
||||
twitchAuth,
|
||||
|
@ -421,6 +427,55 @@ describe('OAuth Middleware', () => {
|
|||
return c.json(response)
|
||||
})
|
||||
|
||||
// MSEntra
|
||||
app.use(
|
||||
'/msentra',
|
||||
msentraAuth({
|
||||
client_id,
|
||||
client_secret,
|
||||
tenant_id: 'fake-tenant-id',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
})
|
||||
)
|
||||
app.use('/msentra-custom-redirect', (c, next) => {
|
||||
return msentraAuth({
|
||||
client_id,
|
||||
client_secret,
|
||||
tenant_id: 'fake-tenant-id',
|
||||
scope: ['openid', 'email', 'profile'],
|
||||
redirect_uri: 'http://localhost:3000/msentra',
|
||||
})(c, next)
|
||||
})
|
||||
app.get('/msentra', (c) => {
|
||||
const user = c.get('user-msentra')
|
||||
const token = c.get('token')
|
||||
const grantedScopes = c.get('granted-scopes')
|
||||
|
||||
return c.json({
|
||||
user,
|
||||
token,
|
||||
grantedScopes,
|
||||
})
|
||||
})
|
||||
app.get('/msentra/refresh', async (c) => {
|
||||
const response = await msentraRefresh({
|
||||
client_id,
|
||||
client_secret,
|
||||
tenant_id: 'fake-tenant-id',
|
||||
refresh_token: msentraRefreshToken,
|
||||
})
|
||||
return c.json(response)
|
||||
})
|
||||
app.get('/msentra/refresh/error', async (c) => {
|
||||
const response = await msentraRefresh({
|
||||
client_id,
|
||||
client_secret,
|
||||
tenant_id: 'fake-tenant-id',
|
||||
refresh_token: 'wrong-refresh-token',
|
||||
})
|
||||
return c.json(response)
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen()
|
||||
})
|
||||
|
@ -973,4 +1028,77 @@ describe('OAuth Middleware', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('msentraAuth middleware', () => {
|
||||
describe('middleware', () => {
|
||||
it('Should redirect', async () => {
|
||||
const res = await app.request('/msentra')
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(302)
|
||||
expect(res.headers)
|
||||
})
|
||||
|
||||
it('Should redirect to custom redirect_uri', async () => {
|
||||
const res = await app.request('/msentra-custom-redirect')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(302)
|
||||
const redirectLocation = res.headers.get('location')!
|
||||
const redirectUrl = new URL(redirectLocation)
|
||||
expect(redirectUrl.searchParams.get('redirect_uri')).toBe('http://localhost:3000/msentra')
|
||||
})
|
||||
|
||||
it('Prevent CSRF attack', async () => {
|
||||
const res = await app.request(`/msentra?code=${dummyCode}&state=malware-state`)
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('Should throw error for invalid code', async () => {
|
||||
const res = await app.request('/msentra?code=9348ffdsd-sdsdbad-code')
|
||||
const text = await res.text()
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(400)
|
||||
expect(text).toBe(msentraCodeError.error)
|
||||
})
|
||||
|
||||
it('Should work with received code', async () => {
|
||||
const res = await app.request(`/msentra?code=${dummyCode}`)
|
||||
const response = (await res.json()) as {
|
||||
token: Token
|
||||
user: MSEntraUser
|
||||
grantedScopes: string[]
|
||||
}
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(response.user).toEqual(msentraUser)
|
||||
expect(response.grantedScopes).toEqual(msentraToken.scope.split(' '))
|
||||
expect(response.token).toEqual({
|
||||
token: msentraToken.access_token,
|
||||
expires_in: msentraToken.expires_in,
|
||||
refresh_token: msentraToken.refresh_token,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Refresh Token', () => {
|
||||
it('Should refresh token', async () => {
|
||||
const res = await app.request('/msentra/refresh')
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(await res.json()).toEqual(msentraToken)
|
||||
})
|
||||
|
||||
it('Should return error for refresh', async () => {
|
||||
const res = await app.request('/msentra/refresh/error')
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.text()).toBe(msentraCodeError.error)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
import { HTTPException } from 'hono/http-exception'
|
||||
|
||||
import { toQueryParams } from '../../utils/objectToQuery'
|
||||
import type { MSEntraErrorResponse, MSEntraToken, MSEntraTokenResponse, MSEntraUser } from './types'
|
||||
|
||||
type MSEntraAuthFlow = {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
tenant_id: string
|
||||
redirect_uri: string
|
||||
code: string | undefined
|
||||
token: MSEntraToken | undefined
|
||||
scope: string[]
|
||||
state?: string
|
||||
}
|
||||
|
||||
export class AuthFlow {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
tenant_id: string
|
||||
redirect_uri: string
|
||||
code: string | undefined
|
||||
token: MSEntraToken | undefined
|
||||
scope: string[]
|
||||
state: string | undefined
|
||||
user: Partial<MSEntraUser> | undefined
|
||||
granted_scopes: string[] | undefined
|
||||
|
||||
constructor({
|
||||
client_id,
|
||||
client_secret,
|
||||
tenant_id,
|
||||
redirect_uri,
|
||||
code,
|
||||
token,
|
||||
scope,
|
||||
state,
|
||||
}: MSEntraAuthFlow) {
|
||||
this.client_id = client_id
|
||||
this.client_secret = client_secret
|
||||
this.tenant_id = tenant_id
|
||||
this.redirect_uri = redirect_uri
|
||||
this.code = code
|
||||
this.token = token
|
||||
this.scope = scope
|
||||
this.state = state
|
||||
this.user = undefined
|
||||
|
||||
if (
|
||||
this.client_id === undefined ||
|
||||
this.client_secret === undefined ||
|
||||
this.tenant_id === undefined ||
|
||||
this.scope.length <= 0
|
||||
) {
|
||||
throw new HTTPException(400, {
|
||||
message: 'Required parameters were not found. Please provide them to proceed.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
redirect() {
|
||||
const parsedOptions = toQueryParams({
|
||||
response_type: 'code',
|
||||
redirect_uri: this.redirect_uri,
|
||||
client_id: this.client_id,
|
||||
include_granted_scopes: true,
|
||||
scope: this.scope.join(' '),
|
||||
state: this.state,
|
||||
})
|
||||
return `https://login.microsoft.com/${this.tenant_id}/oauth2/v2.0/authorize?${parsedOptions}`
|
||||
}
|
||||
|
||||
async getTokenFromCode() {
|
||||
const parsedOptions = toQueryParams({
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
redirect_uri: this.redirect_uri,
|
||||
code: this.code,
|
||||
grant_type: 'authorization_code',
|
||||
})
|
||||
const response = (await fetch(
|
||||
`https://login.microsoft.com/${this.tenant_id}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: parsedOptions,
|
||||
}
|
||||
).then((res) => res.json())) as MSEntraTokenResponse | MSEntraErrorResponse
|
||||
|
||||
if ('error' in response) {
|
||||
throw new HTTPException(400, { message: response.error })
|
||||
}
|
||||
|
||||
if ('access_token' in response) {
|
||||
this.token = {
|
||||
token: response.access_token,
|
||||
expires_in: response.expires_in,
|
||||
refresh_token: response.refresh_token,
|
||||
}
|
||||
|
||||
this.granted_scopes = response.scope.split(' ')
|
||||
}
|
||||
}
|
||||
|
||||
async getUserData() {
|
||||
await this.getTokenFromCode()
|
||||
//TODO: add support for extra fields
|
||||
const response = (await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||
headers: {
|
||||
authorization: `Bearer ${this.token?.token}`,
|
||||
},
|
||||
}).then(async (res) => res.json())) as MSEntraUser | MSEntraErrorResponse
|
||||
|
||||
if ('error' in response) {
|
||||
throw new HTTPException(400, { message: response.error })
|
||||
}
|
||||
|
||||
if ('id' in response) {
|
||||
this.user = response
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export { msentraAuth } from './msentraAuth'
|
||||
export { refreshToken } from './refreshToken'
|
||||
export * from './types'
|
||||
import type { OAuthVariables } from '../../types'
|
||||
import type { MSEntraUser } from './types'
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap extends OAuthVariables {
|
||||
'user-msentra': Partial<MSEntraUser> | undefined
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import type { MiddlewareHandler } from 'hono'
|
||||
import { env } from 'hono/adapter'
|
||||
import { getCookie, setCookie } from 'hono/cookie'
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
|
||||
import { getRandomState } from '../../utils/getRandomState'
|
||||
import { AuthFlow } from './authFlow'
|
||||
|
||||
export function msentraAuth(options: {
|
||||
client_id?: string
|
||||
client_secret?: string
|
||||
tenant_id?: string
|
||||
redirect_uri?: string
|
||||
code?: string | undefined
|
||||
scope: string[]
|
||||
state?: string
|
||||
}): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
// Generate encoded "keys" if not provided
|
||||
const newState = options.state || getRandomState()
|
||||
// Create new Auth instance
|
||||
const auth = new AuthFlow({
|
||||
client_id: options.client_id || (env(c).MSENTRA_ID as string),
|
||||
client_secret: options.client_secret || (env(c).MSENTRA_SECRET as string),
|
||||
tenant_id: options.tenant_id || (env(c).MSENTRA_TENANT_ID as string),
|
||||
redirect_uri: options.redirect_uri || c.req.url.split('?')[0],
|
||||
code: c.req.query('code'),
|
||||
token: {
|
||||
token: c.req.query('access_token') as string,
|
||||
expires_in: Number(c.req.query('expires_in')) as number,
|
||||
},
|
||||
scope: options.scope,
|
||||
})
|
||||
|
||||
// Redirect to login dialog
|
||||
if (!auth.code) {
|
||||
setCookie(c, 'state', newState, {
|
||||
maxAge: 60 * 10,
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
})
|
||||
return c.redirect(auth.redirect())
|
||||
}
|
||||
|
||||
// Avoid CSRF attack by checking state
|
||||
if (c.req.url.includes('?')) {
|
||||
const storedState = getCookie(c, 'state')
|
||||
if (c.req.query('state') !== storedState) {
|
||||
throw new HTTPException(401)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve user data from Microsoft Entra
|
||||
await auth.getUserData()
|
||||
|
||||
// Set return info
|
||||
c.set('token', auth.token)
|
||||
c.set('user-msentra', auth.user)
|
||||
c.set('granted-scopes', auth.granted_scopes)
|
||||
|
||||
await next()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { HTTPException } from 'hono/http-exception'
|
||||
import { toQueryParams } from '../../utils/objectToQuery'
|
||||
import type { MSEntraErrorResponse, MSEntraTokenResponse } from './types'
|
||||
|
||||
export async function refreshToken({
|
||||
client_id,
|
||||
client_secret,
|
||||
tenant_id,
|
||||
refresh_token,
|
||||
}: {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
tenant_id: string
|
||||
refresh_token: string
|
||||
}) {
|
||||
if (!refresh_token) {
|
||||
throw new HTTPException(400, { message: 'missing refresh token' })
|
||||
}
|
||||
|
||||
const params = toQueryParams({
|
||||
client_id,
|
||||
client_secret,
|
||||
refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
})
|
||||
|
||||
const response = (await fetch(`https://login.microsoft.com/${tenant_id}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params,
|
||||
}).then((res) => res.json())) as MSEntraTokenResponse | MSEntraErrorResponse
|
||||
|
||||
if ('error' in response) {
|
||||
throw new HTTPException(400, { message: response.error })
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import type { Token } from '../../types'
|
||||
|
||||
export type MSEntraErrorResponse = {
|
||||
error: string
|
||||
error_description: string
|
||||
error_codes: number[]
|
||||
}
|
||||
|
||||
export type MSEntraTokenResponse = {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
scope: string
|
||||
token_type: string
|
||||
id_token: string
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export type MSEntraUser = {
|
||||
id: string
|
||||
upn: string
|
||||
verified_email: boolean
|
||||
name: string
|
||||
given_name: string
|
||||
family_name: string
|
||||
picture: string
|
||||
local: string
|
||||
employeeId: string
|
||||
}
|
||||
|
||||
export type MSEntraToken = Token & {
|
||||
refresh_token?: string
|
||||
}
|
|
@ -162,7 +162,7 @@ export interface TwitchUserResponse {
|
|||
view_count: number
|
||||
email: string
|
||||
created_at: string
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ const getMetricConstructor = (type: MetricOptions['type']) =>
|
|||
({
|
||||
counter: Counter,
|
||||
histogram: Histogram,
|
||||
}[type])
|
||||
})[type]
|
||||
|
||||
export const createStandardMetrics = ({
|
||||
registry,
|
||||
|
|
|
@ -9,9 +9,8 @@ import * as zodSchemas from './__schemas__/zod'
|
|||
import { sValidator } from '.'
|
||||
|
||||
type ExtractSchema<T> = T extends Hono<infer _, infer S> ? S : never
|
||||
type MergeDiscriminatedUnion<U> = UnionToIntersection<U> extends infer O
|
||||
? { [K in keyof O]: O[K] }
|
||||
: never
|
||||
type MergeDiscriminatedUnion<U> =
|
||||
UnionToIntersection<U> extends infer O ? { [K in keyof O]: O[K] } : never
|
||||
|
||||
const libs = ['valibot', 'zod', 'arktype'] as const
|
||||
const schemasByLibrary = {
|
||||
|
|
|
@ -10,7 +10,7 @@ type Hook<
|
|||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
O = {}
|
||||
O = {},
|
||||
> = (
|
||||
result: (
|
||||
| { success: true; data: T }
|
||||
|
@ -42,7 +42,7 @@ const sValidator = <
|
|||
}
|
||||
out: { [K in Target]: Out }
|
||||
},
|
||||
V extends I = I
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: Schema,
|
||||
|
|
|
@ -148,7 +148,7 @@ export const renderSwaggerUIOptions = (options: DistSwaggerUIOptions) => {
|
|||
return ''
|
||||
}
|
||||
})
|
||||
.filter(item => item !== '')
|
||||
.filter((item) => item !== '')
|
||||
.join(',')
|
||||
|
||||
return optionsStrings
|
||||
|
|
|
@ -68,7 +68,7 @@ export function tbValidator<
|
|||
V extends {
|
||||
in: { [K in Target]: Static<T> }
|
||||
out: { [K in Target]: ExcludeResponseType<Static<T>> }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
|
|
|
@ -11,54 +11,57 @@ interface IFailure<T> {
|
|||
type BaseType<T> = T extends string
|
||||
? string
|
||||
: T extends number
|
||||
? number
|
||||
: T extends boolean
|
||||
? boolean
|
||||
: T extends symbol
|
||||
? symbol
|
||||
: T extends bigint
|
||||
? bigint
|
||||
: T
|
||||
type Parsed<T> = T extends Record<string | number, any>
|
||||
? {
|
||||
[K in keyof T]-?: T[K] extends (infer U)[]
|
||||
? (BaseType<U> | null | undefined)[] | undefined
|
||||
: BaseType<T[K]> | null | undefined
|
||||
}
|
||||
: BaseType<T>
|
||||
? number
|
||||
: T extends boolean
|
||||
? boolean
|
||||
: T extends symbol
|
||||
? symbol
|
||||
: T extends bigint
|
||||
? bigint
|
||||
: T
|
||||
type Parsed<T> =
|
||||
T extends Record<string | number, any>
|
||||
? {
|
||||
[K in keyof T]-?: T[K] extends (infer U)[]
|
||||
? (BaseType<U> | null | undefined)[] | undefined
|
||||
: BaseType<T[K]> | null | undefined
|
||||
}
|
||||
: BaseType<T>
|
||||
|
||||
export type QueryValidation<O extends Record<string | number, any> = any> = (
|
||||
input: string | URLSearchParams
|
||||
) => IValidation<O>
|
||||
export type QueryOutputType<T> = T extends QueryValidation<infer O> ? O : never
|
||||
type QueryStringify<T> = T extends Record<string | number, any>
|
||||
? {
|
||||
// Suppress to split union types
|
||||
[K in keyof T]: [T[K]] extends [bigint | number | boolean]
|
||||
? `${T[K]}`
|
||||
: T[K] extends (infer U)[]
|
||||
? [U] extends [bigint | number | boolean]
|
||||
? `${U}`[]
|
||||
: T[K]
|
||||
: T[K]
|
||||
}
|
||||
: T
|
||||
type QueryStringify<T> =
|
||||
T extends Record<string | number, any>
|
||||
? {
|
||||
// Suppress to split union types
|
||||
[K in keyof T]: [T[K]] extends [bigint | number | boolean]
|
||||
? `${T[K]}`
|
||||
: T[K] extends (infer U)[]
|
||||
? [U] extends [bigint | number | boolean]
|
||||
? `${U}`[]
|
||||
: T[K]
|
||||
: T[K]
|
||||
}
|
||||
: T
|
||||
export type HeaderValidation<O extends Record<string | number, any> = any> = (
|
||||
input: Record<string, string | string[] | undefined>
|
||||
) => IValidation<O>
|
||||
export type HeaderOutputType<T> = T extends HeaderValidation<infer O> ? O : never
|
||||
type HeaderStringify<T> = T extends Record<string | number, any>
|
||||
? {
|
||||
// Suppress to split union types
|
||||
[K in keyof T]: [T[K]] extends [bigint | number | boolean]
|
||||
? `${T[K]}`
|
||||
: T[K] extends (infer U)[]
|
||||
? [U] extends [bigint | number | boolean]
|
||||
? `${U}`
|
||||
: U
|
||||
: T[K]
|
||||
}
|
||||
: T
|
||||
type HeaderStringify<T> =
|
||||
T extends Record<string | number, any>
|
||||
? {
|
||||
// Suppress to split union types
|
||||
[K in keyof T]: [T[K]] extends [bigint | number | boolean]
|
||||
? `${T[K]}`
|
||||
: T[K] extends (infer U)[]
|
||||
? [U] extends [bigint | number | boolean]
|
||||
? `${U}`
|
||||
: U
|
||||
: T[K]
|
||||
}
|
||||
: T
|
||||
|
||||
export type HttpHook<T, E extends Env, P extends string, O = {}> = (
|
||||
result: IValidation.ISuccess<T> | IFailure<Parsed<T>>,
|
||||
|
@ -82,7 +85,7 @@ interface TypiaValidator {
|
|||
V extends { in: { query: QueryStringify<O> }; out: { query: O } } = {
|
||||
in: { query: QueryStringify<O> }
|
||||
out: { query: O }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: 'query',
|
||||
validate: T,
|
||||
|
@ -97,7 +100,7 @@ interface TypiaValidator {
|
|||
V extends { in: { header: HeaderStringify<O> }; out: { header: O } } = {
|
||||
in: { header: HeaderStringify<O> }
|
||||
out: { header: O }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: 'header',
|
||||
validate: T,
|
||||
|
@ -116,7 +119,7 @@ interface TypiaValidator {
|
|||
} = {
|
||||
in: { [K in Target]: O }
|
||||
out: { [K in Target]: O }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
validate: T,
|
||||
|
|
|
@ -23,7 +23,7 @@ export const typiaValidator = <
|
|||
} = {
|
||||
in: { [K in Target]: O }
|
||||
out: { [K in Target]: O }
|
||||
}
|
||||
},
|
||||
>(
|
||||
target: Target,
|
||||
validate: T,
|
||||
|
|
|
@ -14,7 +14,7 @@ export type Hook<
|
|||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
O = {}
|
||||
O = {},
|
||||
> = (
|
||||
result: SafeParseResult<T> & {
|
||||
target: Target
|
||||
|
@ -45,7 +45,7 @@ export const vValidator = <
|
|||
}
|
||||
out: { [K in Target]: Out }
|
||||
},
|
||||
V extends I = I
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
# @hono/zod-openapi
|
||||
|
||||
## 0.19.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`8ed99d9d791ed6bd8b897c705289b0464947e632`](https://github.com/honojs/middleware/commit/8ed99d9d791ed6bd8b897c705289b0464947e632)]:
|
||||
- @hono/zod-validator@0.5.0
|
||||
|
||||
## 0.19.5
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@hono/zod-openapi",
|
||||
"version": "0.19.5",
|
||||
"version": "0.19.6",
|
||||
"description": "A wrapper class of Hono which supports OpenAPI.",
|
||||
"type": "module",
|
||||
"module": "dist/index.js",
|
||||
|
|
|
@ -73,7 +73,7 @@ type IsForm<T> = T extends string
|
|||
type ReturnJsonOrTextOrResponse<
|
||||
ContentType,
|
||||
Content,
|
||||
Status extends keyof StatusCodeRangeDefinitions | StatusCode
|
||||
Status extends keyof StatusCodeRangeDefinitions | StatusCode,
|
||||
> = ContentType extends string
|
||||
? ContentType extends `application/${infer Start}json${infer _End}`
|
||||
? Start extends '' | `${string}+` | `vnd.${string}+`
|
||||
|
@ -88,8 +88,8 @@ type ReturnJsonOrTextOrResponse<
|
|||
>
|
||||
: never
|
||||
: ContentType extends `text/plain${infer _Rest}`
|
||||
? TypedResponse<Content, ExtractStatusCode<Status>, 'text'>
|
||||
: Response
|
||||
? TypedResponse<Content, ExtractStatusCode<Status>, 'text'>
|
||||
: Response
|
||||
: never
|
||||
|
||||
type RequestPart<R extends RouteConfig, Part extends string> = Part extends keyof R['request']
|
||||
|
@ -101,7 +101,7 @@ type HasUndefined<T> = undefined extends T ? true : false
|
|||
type InputTypeBase<
|
||||
R extends RouteConfig,
|
||||
Part extends string,
|
||||
Type extends keyof ValidationTargets
|
||||
Type extends keyof ValidationTargets,
|
||||
> = R['request'] extends RequestTypes
|
||||
? RequestPart<R, Part> extends ZodType
|
||||
? {
|
||||
|
@ -125,22 +125,22 @@ type InputTypeJson<R extends RouteConfig> = R['request'] extends RequestTypes
|
|||
? IsJson<keyof R['request']['body']['content']> extends never
|
||||
? {}
|
||||
: R['request']['body']['content'][keyof R['request']['body']['content']] extends Record<
|
||||
'schema',
|
||||
ZodSchema<any>
|
||||
>
|
||||
? {
|
||||
in: {
|
||||
json: z.input<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
'schema',
|
||||
ZodSchema<any>
|
||||
>
|
||||
? {
|
||||
in: {
|
||||
json: z.input<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
out: {
|
||||
json: z.output<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
}
|
||||
out: {
|
||||
json: z.output<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
}
|
||||
: {}
|
||||
: {}
|
||||
: {}
|
||||
: {}
|
||||
: {}
|
||||
|
@ -151,22 +151,22 @@ type InputTypeForm<R extends RouteConfig> = R['request'] extends RequestTypes
|
|||
? IsForm<keyof R['request']['body']['content']> extends never
|
||||
? {}
|
||||
: R['request']['body']['content'][keyof R['request']['body']['content']] extends Record<
|
||||
'schema',
|
||||
ZodSchema<any>
|
||||
>
|
||||
? {
|
||||
in: {
|
||||
form: z.input<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
'schema',
|
||||
ZodSchema<any>
|
||||
>
|
||||
? {
|
||||
in: {
|
||||
form: z.input<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
out: {
|
||||
form: z.output<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
}
|
||||
out: {
|
||||
form: z.output<
|
||||
R['request']['body']['content'][keyof R['request']['body']['content']]['schema']
|
||||
>
|
||||
}
|
||||
}
|
||||
: {}
|
||||
: {}
|
||||
: {}
|
||||
: {}
|
||||
: {}
|
||||
|
@ -249,8 +249,8 @@ type HonoInit<E extends Env> = ConstructorParameters<typeof Hono>[0] & OpenAPIHo
|
|||
type AsArray<T> = T extends undefined // TODO move to utils?
|
||||
? []
|
||||
: T extends any[]
|
||||
? T
|
||||
: [T]
|
||||
? T
|
||||
: [T]
|
||||
|
||||
/**
|
||||
* Like simplify but recursive
|
||||
|
@ -265,17 +265,14 @@ export type DeepSimplify<T> = {
|
|||
/**
|
||||
* Helper to infer generics from {@link MiddlewareHandler}
|
||||
*/
|
||||
export type OfHandlerType<T extends MiddlewareHandler> = T extends MiddlewareHandler<
|
||||
infer E,
|
||||
infer P,
|
||||
infer I
|
||||
>
|
||||
? {
|
||||
env: E
|
||||
path: P
|
||||
input: I
|
||||
}
|
||||
: never
|
||||
export type OfHandlerType<T extends MiddlewareHandler> =
|
||||
T extends MiddlewareHandler<infer E, infer P, infer I>
|
||||
? {
|
||||
env: E
|
||||
path: P
|
||||
input: I
|
||||
}
|
||||
: never
|
||||
|
||||
/**
|
||||
* Reduce a tuple of middleware handlers into a single
|
||||
|
@ -285,7 +282,7 @@ export type OfHandlerType<T extends MiddlewareHandler> = T extends MiddlewareHan
|
|||
export type MiddlewareToHandlerType<M extends MiddlewareHandler<any, any, any>[]> = M extends [
|
||||
infer First,
|
||||
infer Second,
|
||||
...infer Rest
|
||||
...infer Rest,
|
||||
]
|
||||
? First extends MiddlewareHandler<any, any, any>
|
||||
? Second extends MiddlewareHandler<any, any, any>
|
||||
|
@ -297,23 +294,22 @@ export type MiddlewareToHandlerType<M extends MiddlewareHandler<any, any, any>[]
|
|||
OfHandlerType<First>['path'], // Keep path from First
|
||||
OfHandlerType<First>['input'] // Keep input from First
|
||||
>,
|
||||
...Rest
|
||||
...Rest,
|
||||
]
|
||||
>
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
: M extends [infer Last]
|
||||
? Last // Return the last remaining handler in the array
|
||||
: MiddlewareHandler<Env>
|
||||
? Last // Return the last remaining handler in the array
|
||||
: MiddlewareHandler<Env>
|
||||
|
||||
type RouteMiddlewareParams<R extends RouteConfig> = OfHandlerType<
|
||||
MiddlewareToHandlerType<AsArray<R['middleware']>>
|
||||
>
|
||||
|
||||
export type RouteConfigToEnv<R extends RouteConfig> = RouteMiddlewareParams<R> extends never
|
||||
? Env
|
||||
: RouteMiddlewareParams<R>['env']
|
||||
export type RouteConfigToEnv<R extends RouteConfig> =
|
||||
RouteMiddlewareParams<R> extends never ? Env : RouteMiddlewareParams<R>['env']
|
||||
|
||||
export type RouteHandler<
|
||||
R extends RouteConfig,
|
||||
|
@ -324,7 +320,7 @@ export type RouteHandler<
|
|||
InputTypeCookie<R> &
|
||||
InputTypeForm<R> &
|
||||
InputTypeJson<R>,
|
||||
P extends string = ConvertPathType<R['path']>
|
||||
P extends string = ConvertPathType<R['path']>,
|
||||
> = Handler<
|
||||
E,
|
||||
P,
|
||||
|
@ -352,7 +348,7 @@ export type RouteHook<
|
|||
InputTypeCookie<R> &
|
||||
InputTypeForm<R> &
|
||||
InputTypeJson<R>,
|
||||
P extends string = ConvertPathType<R['path']>
|
||||
P extends string = ConvertPathType<R['path']>,
|
||||
> = Hook<
|
||||
I,
|
||||
E,
|
||||
|
@ -371,7 +367,7 @@ export type OpenAPIObjectConfigure<E extends Env, P extends string> =
|
|||
export class OpenAPIHono<
|
||||
E extends Env = Env,
|
||||
S extends Schema = {},
|
||||
BasePath extends string = '/'
|
||||
BasePath extends string = '/',
|
||||
> extends Hono<E, S, BasePath> {
|
||||
openAPIRegistry: OpenAPIRegistry
|
||||
defaultHook?: OpenAPIHonoOptions<E>['defaultHook']
|
||||
|
@ -421,7 +417,7 @@ export class OpenAPIHono<
|
|||
InputTypeCookie<R> &
|
||||
InputTypeForm<R> &
|
||||
InputTypeJson<R>,
|
||||
P extends string = ConvertPathType<R['path']>
|
||||
P extends string = ConvertPathType<R['path']>,
|
||||
>(
|
||||
{ middleware: routeMiddleware, hide, ...route }: R,
|
||||
handler: Handler<
|
||||
|
@ -609,7 +605,7 @@ export class OpenAPIHono<
|
|||
SubPath extends string,
|
||||
SubEnv extends Env,
|
||||
SubSchema extends Schema,
|
||||
SubBasePath extends string
|
||||
SubBasePath extends string,
|
||||
>(
|
||||
path: SubPath,
|
||||
app: Hono<SubEnv, SubSchema, SubBasePath>
|
||||
|
@ -619,7 +615,7 @@ export class OpenAPIHono<
|
|||
SubPath extends string,
|
||||
SubEnv extends Env,
|
||||
SubSchema extends Schema,
|
||||
SubBasePath extends string
|
||||
SubBasePath extends string,
|
||||
>(
|
||||
path: SubPath,
|
||||
app?: Hono<SubEnv, SubSchema, SubBasePath>
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
# @hono/zod-validator
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1140](https://github.com/honojs/middleware/pull/1140) [`8ed99d9d791ed6bd8b897c705289b0464947e632`](https://github.com/honojs/middleware/commit/8ed99d9d791ed6bd8b897c705289b0464947e632) Thanks [@yusukebe](https://github.com/yusukebe)! - feat: add `validationFunction` option
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
@ -68,6 +68,30 @@ app.post(
|
|||
)
|
||||
```
|
||||
|
||||
### Custom validation function
|
||||
|
||||
By default, this Validator validates values using `.safeParseAsync`.
|
||||
|
||||
```ts
|
||||
await schema.safeParseAsync(value)
|
||||
```
|
||||
|
||||
But, if you want to use the [`.passthrough`](https://zod.dev/?id=passthrough), you can specify your own function in `validationFunction`.
|
||||
|
||||
```ts
|
||||
app.post(
|
||||
'/',
|
||||
zValidator('json', schema, undefined, {
|
||||
validationFunction: async (schema, value) => {
|
||||
return await schema.passthrough().safeParseAsync(value)
|
||||
},
|
||||
}),
|
||||
(c) => {
|
||||
// ...
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Author
|
||||
|
||||
Yusuke Wada <https://github.com/yusukebe>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@hono/zod-validator",
|
||||
"version": "0.4.3",
|
||||
"version": "0.5.0",
|
||||
"description": "Validator middleware using Zod",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
@ -378,3 +378,85 @@ describe('Case-Insensitive Headers', () => {
|
|||
type verify = Expect<Equal<Expected, Actual>>
|
||||
})
|
||||
})
|
||||
|
||||
describe('With options + validationFunction', () => {
|
||||
const app = new Hono()
|
||||
const jsonSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
})
|
||||
|
||||
const route = app
|
||||
.post('/', zValidator('json', jsonSchema), (c) => {
|
||||
const data = c.req.valid('json')
|
||||
return c.json({
|
||||
success: true,
|
||||
data,
|
||||
})
|
||||
})
|
||||
.post(
|
||||
'/extended',
|
||||
zValidator('json', jsonSchema, undefined, {
|
||||
validationFunction: async (schema, value) => {
|
||||
return await schema.passthrough().safeParseAsync(value)
|
||||
},
|
||||
}),
|
||||
(c) => {
|
||||
const data = c.req.valid('json')
|
||||
return c.json({
|
||||
success: true,
|
||||
data,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('Should be ok due to passthrough schema', async () => {
|
||||
const req = new Request('http://localhost/extended', {
|
||||
body: JSON.stringify({
|
||||
name: 'Superman',
|
||||
age: 20,
|
||||
length: 170,
|
||||
weight: 55,
|
||||
}),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
const res = await app.request(req)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
name: 'Superman',
|
||||
age: 20,
|
||||
length: 170,
|
||||
weight: 55,
|
||||
},
|
||||
})
|
||||
})
|
||||
it('Should be ok due to required schema', async () => {
|
||||
const req = new Request('http://localhost', {
|
||||
body: JSON.stringify({
|
||||
name: 'Superman',
|
||||
age: 20,
|
||||
}),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
const res = await app.request(req)
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
name: 'Superman',
|
||||
age: 20,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import type { Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets } from 'hono'
|
||||
import { validator } from 'hono/validator'
|
||||
import { ZodObject } from 'zod'
|
||||
import type { ZodError, ZodSchema, z } from 'zod'
|
||||
import type { SafeParseReturnType, ZodError, ZodSchema, z } from 'zod'
|
||||
|
||||
export type Hook<
|
||||
T,
|
||||
E extends Env,
|
||||
P extends string,
|
||||
Target extends keyof ValidationTargets = keyof ValidationTargets,
|
||||
O = {}
|
||||
O = {},
|
||||
> = (
|
||||
result: ({ success: true; data: T } | { success: false; error: ZodError; data: T }) & {
|
||||
target: Target
|
||||
|
@ -39,11 +39,18 @@ export const zValidator = <
|
|||
}
|
||||
out: { [K in Target]: Out }
|
||||
},
|
||||
V extends I = I
|
||||
V extends I = I,
|
||||
>(
|
||||
target: Target,
|
||||
schema: T,
|
||||
hook?: Hook<z.infer<T>, E, P, Target>
|
||||
hook?: Hook<z.infer<T>, E, P, Target>,
|
||||
options?: {
|
||||
validationFunction: (
|
||||
schema: T,
|
||||
value: ValidationTargets[Target]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => SafeParseReturnType<any, any> | Promise<SafeParseReturnType<any, any>>
|
||||
}
|
||||
): MiddlewareHandler<E, P, V> =>
|
||||
// @ts-expect-error not typed well
|
||||
validator(target, async (value, c) => {
|
||||
|
@ -63,7 +70,10 @@ export const zValidator = <
|
|||
)
|
||||
}
|
||||
|
||||
const result = await schema.safeParseAsync(validatorValue)
|
||||
const result =
|
||||
options && options.validationFunction
|
||||
? await options.validationFunction(schema, validatorValue)
|
||||
: await schema.safeParseAsync(validatorValue)
|
||||
|
||||
if (hook) {
|
||||
const hookResult = await hook({ data: validatorValue, ...result, target }, c)
|
||||
|
|
Loading…
Reference in New Issue