Skip to content

Commit 76665d7

Browse files
committed
feat(oauth-providers): Add MSEntra OAuth Provider
1 parent e394e64 commit 76665d7

File tree

10 files changed

+592
-0
lines changed

10 files changed

+592
-0
lines changed

Diff for: .changeset/sixty-cobras-live.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/oauth-providers': minor
3+
---
4+
5+
The PR adds Microsoft Entra (AzureAD) to the list of supported 3rd-party OAuth providers.

Diff for: packages/oauth-providers/README.md

+122
Original file line numberDiff line numberDiff line change
@@ -1086,6 +1086,128 @@ The validation endpoint helps your application detect when tokens become invalid
10861086

10871087
> 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.
10881088
1089+
### MSEntra
1090+
1091+
```ts
1092+
import { Hono } from 'hono'
1093+
import { msentraAuth } from '@hono/oauth-providers/msentra'
1094+
1095+
const app = new Hono()
1096+
1097+
app.use(
1098+
'/msentra',
1099+
msentraAuth({
1100+
client_id: process.env.MSENTRA_ID,
1101+
client_secret: process.env.MSENTRA_SECRET,
1102+
tenant_id: process.env.MSENTRA_TENANT_ID
1103+
scope: [
1104+
'openid',
1105+
'profile',
1106+
'email',
1107+
'https;//graph.microsoft.com/.default',
1108+
]
1109+
})
1110+
)
1111+
1112+
export default app
1113+
```
1114+
1115+
### Parameters
1116+
1117+
- `client_id`:
1118+
- Type: `string`.
1119+
- `Required`.
1120+
- Your app client Id. You can find this in your Azure Portal.
1121+
- `client_secret`:
1122+
- Type: `string`.
1123+
- `Required`.
1124+
- Your app client secret. You can find this in your Azure Portal.
1125+
> ⚠️ Do **not** share your **client secret** to ensure the security of your app.
1126+
- `tenant_id`:
1127+
- Type: `string`
1128+
- `Required`.
1129+
- Your Microsoft Tenant's Id. You can find this in your Azure Portal.
1130+
- `scope`:
1131+
- Type: `string[]`.
1132+
- `Required`.
1133+
- Set of **permissions** to request the user's authorization to access your app for retrieving
1134+
user information and performing actions on their behalf.
1135+
1136+
#### Authentication Flow
1137+
1138+
After the completion of the MSEntra OAuth flow, essential data has been prepared for use in the
1139+
subsequent steps that your app needs to take.
1140+
1141+
`msentraAuth` method provides 4 set key data:
1142+
1143+
- `token`:
1144+
- Access token to make requests to the MSEntra API for retrieving user information and
1145+
performing actions on their behalf.
1146+
- Type:
1147+
```
1148+
{
1149+
token: string
1150+
expires_in: number
1151+
refresh_token: string
1152+
}
1153+
```
1154+
- `granted-scopes`:
1155+
- Scopes for which the user has granted permissions.
1156+
- Type: `string[]`.
1157+
- `user-msentra`:
1158+
- User basic info retrieved from MSEntra
1159+
- Type:
1160+
```
1161+
{
1162+
businessPhones: string[],
1163+
displayName: string
1164+
givenName: string
1165+
jobTitle: string
1166+
mail: string
1167+
mobilePhone: string
1168+
officeLocation: string
1169+
surname: string
1170+
userPrincipalName: string
1171+
id: string
1172+
}
1173+
```
1174+
1175+
> [!NOTE]
1176+
> To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request
1177+
> handler.
1178+
1179+
```ts
1180+
app.get('/msentra', (c) => {
1181+
const token = c.get('token')
1182+
const grantedScopes = c.get('granted-scopes')
1183+
const user = c.get('user-msentra')
1184+
1185+
return c.json({
1186+
token,
1187+
grantedScopes,
1188+
user,
1189+
})
1190+
})
1191+
```
1192+
1193+
#### Refresh Token
1194+
1195+
Once the user token expires you can refresh their token without the need to prompt the user again
1196+
for access. In such scenario, you can utilize the `refreshToken` method, which accepts the
1197+
`client_id`, `client_secret`, `tenant_id`, and `refresh_token` as parameters.
1198+
1199+
> [!NOTE]
1200+
> The `refresh_token` can be used once. Once the token is refreshed MSEntra gives you a new
1201+
> `refresh_token` along with the new token.
1202+
1203+
```ts
1204+
import { msentraAuth, refreshToken } from '@hono/oauth-providers/msentra'
1205+
1206+
app.get('/msentra/refresh', (c, next) => {
1207+
const newTokens = await refreshToken({ client_id, client_secret, tenant_id, refresh_token })
1208+
})
1209+
```
1210+
10891211
## Advance Usage
10901212

10911213
### Customize `redirect_uri`

Diff for: packages/oauth-providers/package.json

+13
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@
9393
"types": "./dist/providers/twitch/index.d.ts",
9494
"default": "./dist/providers/twitch/index.js"
9595
}
96+
},
97+
"./msentra": {
98+
"import": {
99+
"types": "./dist/providers/msentra/index.d.mts",
100+
"default": "./dist/providers/msentra/index.mjs"
101+
},
102+
"require": {
103+
"types": "./dist/providers/msentra/index.d.ts",
104+
"default": "./dist/providers/msentra/index.js"
105+
}
96106
}
97107
},
98108
"typesVersions": {
@@ -117,6 +127,9 @@
117127
],
118128
"twitch": [
119129
"./dist/providers/twitch/index.d.ts"
130+
],
131+
"msentra": [
132+
"./dist/providers/msentra/index.d.ts"
120133
]
121134
}
122135
},
+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { HTTPException } from 'hono/http-exception'
2+
3+
import { toQueryParams } from '../../utils/objectToQuery'
4+
import type { MSEntraErrorResponse, MSEntraToken, MSEntraTokenResponse, MSEntraUser } from './types'
5+
6+
type MSEntraAuthFlow = {
7+
client_id: string
8+
client_secret: string
9+
tenant_id: string
10+
redirect_uri: string
11+
code: string | undefined
12+
token: MSEntraToken | undefined
13+
scope: string[]
14+
state?: string
15+
}
16+
17+
export class AuthFlow {
18+
client_id: string
19+
client_secret: string
20+
tenant_id: string
21+
redirect_uri: string
22+
code: string | undefined
23+
token: MSEntraToken | undefined
24+
scope: string[]
25+
state: string | undefined
26+
user: Partial<MSEntraUser> | undefined
27+
granted_scopes: string[] | undefined
28+
29+
constructor({
30+
client_id,
31+
client_secret,
32+
tenant_id,
33+
redirect_uri,
34+
code,
35+
token,
36+
scope,
37+
state,
38+
}: MSEntraAuthFlow) {
39+
this.client_id = client_id
40+
this.client_secret = client_secret
41+
this.tenant_id = tenant_id
42+
this.redirect_uri = redirect_uri
43+
this.code = code
44+
this.token = token
45+
this.scope = scope
46+
this.state = state
47+
this.user = undefined
48+
49+
if (
50+
this.client_id === undefined ||
51+
this.client_secret === undefined ||
52+
this.tenant_id === undefined ||
53+
this.scope.length <= 0
54+
) {
55+
throw new HTTPException(400, {
56+
message: 'Required parameters were not found. Please provide them to proceed.',
57+
})
58+
}
59+
}
60+
61+
redirect() {
62+
const parsedOptions = toQueryParams({
63+
response_type: 'code',
64+
redirect_uri: this.redirect_uri,
65+
client_id: this.client_id,
66+
include_granted_scopes: true,
67+
scope: this.scope.join(' '),
68+
state: this.state,
69+
})
70+
return `https://login.microsoft.com/${this.tenant_id}/oauth2/v2.0/authorize?${parsedOptions}`
71+
}
72+
73+
async getTokenFromCode() {
74+
const parsedOptions = toQueryParams({
75+
client_id: this.client_id,
76+
client_secret: this.client_secret,
77+
redirect_uri: this.redirect_uri,
78+
code: this.code,
79+
grant_type: 'authorization_code',
80+
})
81+
const response = (await fetch(
82+
`https://login.microsoft.com/${this.tenant_id}/oauth2/v2.0/token`,
83+
{
84+
method: 'POST',
85+
headers: {
86+
'content-type': 'application/x-www-form-urlencoded',
87+
},
88+
body: parsedOptions,
89+
}
90+
).then((res) => res.json())) as MSEntraTokenResponse | MSEntraErrorResponse
91+
92+
if ('error' in response) {
93+
throw new HTTPException(400, { message: response.error })
94+
}
95+
96+
if ('access_token' in response) {
97+
this.token = {
98+
token: response.access_token,
99+
expires_in: response.expires_in,
100+
refresh_token: response.refresh_token,
101+
}
102+
103+
this.granted_scopes = response.scope.split(' ')
104+
}
105+
}
106+
107+
async getUserData() {
108+
await this.getTokenFromCode()
109+
//TODO: add support for extra fields
110+
const response = (await fetch('https://graph.microsoft.com/v1.0/me', {
111+
headers: {
112+
authorization: `Bearer ${this.token?.token}`,
113+
},
114+
}).then(async (res) => res.json())) as MSEntraUser | MSEntraErrorResponse
115+
116+
if ('error' in response) {
117+
throw new HTTPException(400, { message: response.error })
118+
}
119+
120+
if ('id' in response) {
121+
this.user = response
122+
}
123+
}
124+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export { msentraAuth } from './msentraAuth'
2+
export { refreshToken } from './refreshToken'
3+
export * from './types'
4+
import type { OAuthVariables } from '../../types'
5+
import type { MSEntraUser } from './types'
6+
7+
declare module 'hono' {
8+
interface ContextVariableMap extends OAuthVariables {
9+
'user-msentra': Partial<MSEntraUser> | undefined
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { MiddlewareHandler } from 'hono'
2+
import { env } from 'hono/adapter'
3+
import { getCookie, setCookie } from 'hono/cookie'
4+
import { HTTPException } from 'hono/http-exception'
5+
6+
import { getRandomState } from '../../utils/getRandomState'
7+
import { AuthFlow } from './authFlow'
8+
9+
export function msentraAuth(options: {
10+
client_id?: string
11+
client_secret?: string
12+
tenant_id?: string
13+
redirect_uri?: string
14+
code?: string | undefined
15+
scope: string[]
16+
state?: string
17+
}): MiddlewareHandler {
18+
return async (c, next) => {
19+
// Generate encoded "keys" if not provided
20+
const newState = options.state || getRandomState()
21+
// Create new Auth instance
22+
const auth = new AuthFlow({
23+
client_id: options.client_id || (env(c).MSENTRA_ID as string),
24+
client_secret: options.client_secret || (env(c).MSENTRA_SECRET as string),
25+
tenant_id: options.tenant_id || (env(c).MSENTRA_TENANT_ID as string),
26+
redirect_uri: options.redirect_uri || c.req.url.split('?')[0],
27+
code: c.req.query('code'),
28+
token: {
29+
token: c.req.query('access_token') as string,
30+
expires_in: Number(c.req.query('expires_in')) as number,
31+
},
32+
scope: options.scope,
33+
})
34+
35+
// Redirect to login dialog
36+
if (!auth.code) {
37+
setCookie(c, 'state', newState, {
38+
maxAge: 60 * 10,
39+
httpOnly: true,
40+
path: '/',
41+
})
42+
return c.redirect(auth.redirect())
43+
}
44+
45+
// Avoid CSRF attack by checking state
46+
if (c.req.url.includes('?')) {
47+
const storedState = getCookie(c, 'state')
48+
if (c.req.query('state') !== storedState) {
49+
throw new HTTPException(401)
50+
}
51+
}
52+
53+
// Retrieve user data from Microsoft Entra
54+
await auth.getUserData()
55+
56+
// Set return info
57+
c.set('token', auth.token)
58+
c.set('user-msentra', auth.user)
59+
c.set('granted-scopes', auth.granted_scopes)
60+
61+
await next()
62+
}
63+
}

0 commit comments

Comments
 (0)