diff --git a/.changeset/sixty-cobras-live.md b/.changeset/sixty-cobras-live.md new file mode 100644 index 00000000..40e43a74 --- /dev/null +++ b/.changeset/sixty-cobras-live.md @@ -0,0 +1,5 @@ +--- +'@hono/oauth-providers': minor +--- + +The PR adds Microsoft Entra (AzureAD) to the list of supported 3rd-party OAuth providers. diff --git a/packages/oauth-providers/README.md b/packages/oauth-providers/README.md index fd0449ce..87aa09ef 100644 --- a/packages/oauth-providers/README.md +++ b/packages/oauth-providers/README.md @@ -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` diff --git a/packages/oauth-providers/mocks.ts b/packages/oauth-providers/mocks.ts index 34873182..670fcb79 100644 --- a/packages/oauth-providers/mocks.ts +++ b/packages/oauth-providers/mocks.ts @@ -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 | 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 | 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], +} diff --git a/packages/oauth-providers/package.json b/packages/oauth-providers/package.json index 799926d0..363a5dc0 100644 --- a/packages/oauth-providers/package.json +++ b/packages/oauth-providers/package.json @@ -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" ] } }, diff --git a/packages/oauth-providers/src/index.test.ts b/packages/oauth-providers/src/index.test.ts index b63c7ce5..c1ff00b0 100644 --- a/packages/oauth-providers/src/index.test.ts +++ b/packages/oauth-providers/src/index.test.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) + }) + }) + }) }) diff --git a/packages/oauth-providers/src/providers/msentra/authFlow.ts b/packages/oauth-providers/src/providers/msentra/authFlow.ts new file mode 100644 index 00000000..04db8f4d --- /dev/null +++ b/packages/oauth-providers/src/providers/msentra/authFlow.ts @@ -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 | 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 + } + } +} diff --git a/packages/oauth-providers/src/providers/msentra/index.ts b/packages/oauth-providers/src/providers/msentra/index.ts new file mode 100644 index 00000000..bcc6cc28 --- /dev/null +++ b/packages/oauth-providers/src/providers/msentra/index.ts @@ -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 | undefined + } +} diff --git a/packages/oauth-providers/src/providers/msentra/msentraAuth.ts b/packages/oauth-providers/src/providers/msentra/msentraAuth.ts new file mode 100644 index 00000000..8f5365f2 --- /dev/null +++ b/packages/oauth-providers/src/providers/msentra/msentraAuth.ts @@ -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() + } +} diff --git a/packages/oauth-providers/src/providers/msentra/refreshToken.ts b/packages/oauth-providers/src/providers/msentra/refreshToken.ts new file mode 100644 index 00000000..6f94a3f4 --- /dev/null +++ b/packages/oauth-providers/src/providers/msentra/refreshToken.ts @@ -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 +} diff --git a/packages/oauth-providers/src/providers/msentra/types.ts b/packages/oauth-providers/src/providers/msentra/types.ts new file mode 100644 index 00000000..4b411ae3 --- /dev/null +++ b/packages/oauth-providers/src/providers/msentra/types.ts @@ -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 +}