feat(oauth-providers): Add MSEntra OAuth Provider (#1163)

Co-authored-by: Tim Barley <barleyco@gmail.com>
pull/1167/head
Jonathan Haines 2025-05-16 08:38:53 +10:00 committed by GitHub
parent 2c917b33a4
commit cf48336cbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 582 additions and 1 deletions

View File

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

View File

@ -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`

View File

@ -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,30 @@ 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 +587,27 @@ 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],
}

View File

@ -60,6 +60,9 @@
],
"twitch": [
"./dist/providers/twitch/index.d.ts"
],
"msentra": [
"./dist/providers/msentra/index.d.ts"
]
}
},

View File

@ -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)
})
})
})
})

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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
}