Compare commits
86 Commits
2f0016819d
...
5ca182c475
Author | SHA1 | Date |
---|---|---|
|
5ca182c475 | |
|
bed23c62f5 | |
|
b8453438b6 | |
|
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.
|
|
@ -1,5 +1,11 @@
|
||||||
# @hono/node-ws
|
# @hono/node-ws
|
||||||
|
|
||||||
|
## 1.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#1146](https://github.com/honojs/middleware/pull/1146) [`b8453438b66fc9a6af58e33593e9fa21a96c02a7`](https://github.com/honojs/middleware/commit/b8453438b66fc9a6af58e33593e9fa21a96c02a7) Thanks [@nakasyou](https://github.com/nakasyou)! - enhance WebSocket connection handling with CORS support and connection symbols
|
||||||
|
|
||||||
## 1.1.3
|
## 1.1.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@hono/node-ws",
|
"name": "@hono/node-ws",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"description": "WebSocket helper for Node.js",
|
"description": "WebSocket helper for Node.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { serve } from '@hono/node-server'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import type { ServerType } from '@hono/node-server/dist/types'
|
import type { ServerType } from '@hono/node-server/dist/types'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import { cors } from 'hono/cors'
|
||||||
import type { WSMessageReceive } from 'hono/ws'
|
import type { WSMessageReceive } from 'hono/ws'
|
||||||
import { WebSocket } from 'ws'
|
import { WebSocket } from 'ws'
|
||||||
import { createNodeWebSocket } from '.'
|
import { createNodeWebSocket } from '.'
|
||||||
|
@ -244,4 +245,42 @@ describe('WebSocket helper', () => {
|
||||||
createNodeWebSocket({ app })
|
createNodeWebSocket({ app })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should client can connect when use cors()', async () => {
|
||||||
|
app.use(cors())
|
||||||
|
const mainPromise = new Promise<boolean>((resolve) =>
|
||||||
|
app.get(
|
||||||
|
'/',
|
||||||
|
upgradeWebSocket(() => ({
|
||||||
|
onOpen() {
|
||||||
|
resolve(true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
new WebSocket('ws://localhost:3030/')
|
||||||
|
|
||||||
|
expect(await mainPromise).toBe(true)
|
||||||
|
})
|
||||||
|
it('Should client can connect even if a response has difference', async () => {
|
||||||
|
app.use(async (c, next) => {
|
||||||
|
c.res = new Response(null, c.res)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
const mainPromise = new Promise<boolean>((resolve) =>
|
||||||
|
app.get(
|
||||||
|
'/',
|
||||||
|
upgradeWebSocket(() => ({
|
||||||
|
onOpen() {
|
||||||
|
resolve(true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
new WebSocket('ws://localhost:3030/')
|
||||||
|
|
||||||
|
expect(await mainPromise).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -23,6 +23,11 @@ export interface NodeWebSocketInit {
|
||||||
baseUrl?: string | URL
|
baseUrl?: string | URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generateConnectionSymbol = () => Symbol('connection')
|
||||||
|
|
||||||
|
/** @example `c.env[CONNECTION_SYMBOL_KEY]` */
|
||||||
|
const CONNECTION_SYMBOL_KEY: unique symbol = Symbol('CONNECTION_SYMBOL_KEY')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create WebSockets for Node.js
|
* Create WebSockets for Node.js
|
||||||
* @param init Options
|
* @param init Options
|
||||||
|
@ -32,7 +37,7 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
||||||
const wss = new WebSocketServer({ noServer: true })
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
const waiterMap = new Map<
|
const waiterMap = new Map<
|
||||||
IncomingMessage,
|
IncomingMessage,
|
||||||
{ resolve: (ws: WebSocket) => void; response: Response }
|
{ resolve: (ws: WebSocket) => void; connectionSymbol: symbol }
|
||||||
>()
|
>()
|
||||||
|
|
||||||
wss.on('connection', (ws, request) => {
|
wss.on('connection', (ws, request) => {
|
||||||
|
@ -43,9 +48,9 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodeUpgradeWebSocket = (request: IncomingMessage, response: Response) => {
|
const nodeUpgradeWebSocket = (request: IncomingMessage, connectionSymbol: symbol) => {
|
||||||
return new Promise<WebSocket>((resolve) => {
|
return new Promise<WebSocket>((resolve) => {
|
||||||
waiterMap.set(request, { resolve, response })
|
waiterMap.set(request, { resolve, connectionSymbol })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,14 +67,18 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
||||||
headers.append(key, Array.isArray(value) ? value[0] : value)
|
headers.append(key, Array.isArray(value) ? value[0] : value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await init.app.request(
|
const env: {
|
||||||
url,
|
incoming: IncomingMessage
|
||||||
{ headers: headers },
|
outgoing: undefined
|
||||||
{ incoming: request, outgoing: undefined }
|
[CONNECTION_SYMBOL_KEY]?: symbol
|
||||||
)
|
} = {
|
||||||
|
incoming: request,
|
||||||
|
outgoing: undefined,
|
||||||
|
}
|
||||||
|
await init.app.request(url, { headers: headers }, env)
|
||||||
const waiter = waiterMap.get(request)
|
const waiter = waiterMap.get(request)
|
||||||
if (!waiter || waiter.response !== response) {
|
|
||||||
|
if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
|
||||||
socket.end(
|
socket.end(
|
||||||
'HTTP/1.1 400 Bad Request\r\n' +
|
'HTTP/1.1 400 Bad Request\r\n' +
|
||||||
'Connection: close\r\n' +
|
'Connection: close\r\n' +
|
||||||
|
@ -93,9 +102,10 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = new Response()
|
const connectionSymbol = generateConnectionSymbol()
|
||||||
|
c.env[CONNECTION_SYMBOL_KEY] = connectionSymbol
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const ws = await nodeUpgradeWebSocket(c.env.incoming, response)
|
const ws = await nodeUpgradeWebSocket(c.env.incoming, connectionSymbol)
|
||||||
let events: WSEvents<WebSocket>
|
let events: WSEvents<WebSocket>
|
||||||
try {
|
try {
|
||||||
events = await createEvents(c)
|
events = await createEvents(c)
|
||||||
|
@ -167,7 +177,7 @@ export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
|
||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return response
|
return new 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.
|
> 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
|
## Advance Usage
|
||||||
|
|
||||||
### Customize `redirect_uri`
|
### Customize `redirect_uri`
|
||||||
|
|
|
@ -10,6 +10,11 @@ import type {
|
||||||
import type { GitHubErrorResponse, GitHubTokenResponse } from './src/providers/github'
|
import type { GitHubErrorResponse, GitHubTokenResponse } from './src/providers/github'
|
||||||
import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from './src/providers/google'
|
import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from './src/providers/google'
|
||||||
import type { LinkedInErrorResponse, LinkedInTokenResponse } from './src/providers/linkedin'
|
import type { LinkedInErrorResponse, LinkedInTokenResponse } from './src/providers/linkedin'
|
||||||
|
import type {
|
||||||
|
MSEntraErrorResponse,
|
||||||
|
MSEntraTokenResponse,
|
||||||
|
MSEntraUser,
|
||||||
|
} from './src/providers/msentra'
|
||||||
import type {
|
import type {
|
||||||
TwitchErrorResponse,
|
TwitchErrorResponse,
|
||||||
TwitchTokenResponse,
|
TwitchTokenResponse,
|
||||||
|
@ -206,6 +211,31 @@ export const handlers = [
|
||||||
return HttpResponse.json(twitchValidateError, { status: 401 })
|
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'
|
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
|
||||||
|
@ -558,3 +588,28 @@ export const twitchValidateError = {
|
||||||
status: 401,
|
status: 401,
|
||||||
message: 'invalid access token',
|
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"
|
"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": {
|
"import": {
|
||||||
"types": "./dist/providers/*/index.d.ts",
|
"types": "./dist/providers/*/index.d.ts",
|
||||||
|
@ -60,6 +84,9 @@
|
||||||
],
|
],
|
||||||
"twitch": [
|
"twitch": [
|
||||||
"./dist/providers/twitch/index.d.ts"
|
"./dist/providers/twitch/index.d.ts"
|
||||||
|
],
|
||||||
|
"msentra": [
|
||||||
|
"./dist/providers/msentra/index.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -19,6 +19,10 @@ import {
|
||||||
linkedInCodeError,
|
linkedInCodeError,
|
||||||
linkedInToken,
|
linkedInToken,
|
||||||
linkedInUser,
|
linkedInUser,
|
||||||
|
msentraCodeError,
|
||||||
|
msentraRefreshToken,
|
||||||
|
msentraToken,
|
||||||
|
msentraUser,
|
||||||
xCodeError,
|
xCodeError,
|
||||||
xRefreshToken,
|
xRefreshToken,
|
||||||
xRefreshTokenError,
|
xRefreshTokenError,
|
||||||
|
@ -48,6 +52,8 @@ import { googleAuth } from './providers/google'
|
||||||
import type { GoogleUser } from './providers/google'
|
import type { GoogleUser } from './providers/google'
|
||||||
import { linkedinAuth } from './providers/linkedin'
|
import { linkedinAuth } from './providers/linkedin'
|
||||||
import type { LinkedInUser } 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 type { TwitchUser } from './providers/twitch'
|
||||||
import {
|
import {
|
||||||
twitchAuth,
|
twitchAuth,
|
||||||
|
@ -421,6 +427,55 @@ describe('OAuth Middleware', () => {
|
||||||
return c.json(response)
|
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(() => {
|
beforeAll(() => {
|
||||||
server.listen()
|
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
|
||||||
|
}
|
Loading…
Reference in New Issue