Oauth providers (#262)

* [FEAT] facebook and google authentication

* [FEAT] github authentication

* [FEAT] linkedin authentication

* [TEST] all methods tested

* [UPDATE] open-auth middleware split

* [UPDATE] auth packages merged into oauth-provider

* docs: format README

* fix: fix `package.json`, prettify, rename something

* fix: use `ContextVariableMap`

* docs: update README

* make it as a minor release

* [FIX: linkedin] path name

* [UPDATE: linkedin] pathname

---------

Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
pull/266/head
Carlos Sanjines Aldazosa 2023-11-27 08:33:31 -04:00 committed by GitHub
parent f14f0c8cf5
commit d2696c46ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 3886 additions and 7 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/oauth-providers': minor
---
Add oauth-providers middleware

View File

@ -0,0 +1,631 @@
# OAuth Providers Middleware
Authentication middleware for [Hono](https://github.com/honojs/hono). This package offers a straightforward API for social login with platforms such as Facebook, GitHub, Google, and LinkedIn.
## Installation
You can install `hono` and `@hono/oauth-providers` via npm.
```txt
npm i hono @hono/oauth-providers
```
## Usage
Open Auth simplifies the OAuth2 flow, enabling you to utilize social login with just a single method.
On every platform you choose to add to your project you have to add on its platform the **callback uri** or **redirect uri**. Open Auth handles the redirect uri internally as the route you are using the middleware on, so if you decide to use the google auth on the route `/api/v1/auth/google/` the redirect uri will be `DOMAIN/api/v1/auth/google`.
```ts
app.use(
"api/v1/auth/google", // -> redirect_uri by default
googleAuth({ ... })
)
```
Also, there is two ways to use this middleware:
```ts
app.use(
'/google',
googleAuth({
client_id: Bun.env.GOOGLE_ID,
client_secret: Bun.env.GOOGLE_SECRET,
scope: ['openid', 'email', 'profile'],
})
)
app.get('/google', (c) => {
const token = c.get('token')
const grantedScopes = c.get('granted-scopes')
const user = c.get('user-google')
return c.json({
token,
grantedScopes,
user,
})
})
export default app
```
Or
```ts
app.get(
'/google',
googleAuth({
client_id: Bun.env.GOOGLE_ID,
client_secret: Bun.env.GOOGLE_SECRET,
scope: ['openid', 'email', 'profile'],
}),
(c) => {
const token = c.get('token')
const grantedScopes = c.get('granted-scopes')
const user = c.get('user-google')
return c.json({
token,
grantedScopes,
user,
})
}
)
export default app
```
### Google
```ts
import { Hono } from 'hono'
import { googleAuth } from '@hono/oauth-providers/google'
const app = new Hono()
app.use(
'/google',
googleAuth({
client_id: Bun.env.GOOGLE_ID,
client_secret: Bun.env.GOOGLE_SECRET,
scope: ['openid', 'email', 'profile'],
})
)
export default app
```
#### Parameters
- `client_id`:
- Type: `string`.
- `Required`.
- Your app client ID. You can find this value in the API Console [Credentials page](https://console.developers.google.com/apis/credentials). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `GOOGLE_ID=`.
- `client_secret`:
- Type: `string`.
- `Required`.
- Your app client secret. You can find this value in the API Console [Credentials page](https://console.developers.google.com/apis/credentials). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `GOOGLE_SECRET=`.
> Do not share your client secret to ensure the security of your app.
- `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.<br /> Review all the scopes Google offers for utilizing their API on the [OAuth 2.0 Scopes page](https://developers.google.com/identity/protocols/oauth2/scopes).
> If your app is not **verified** by Google, the accessible scopes for your app are significantly **limited**.
- `login_hint`:
- Type: `string`.
- `Optional`.
- Set the parameter value to an email address or `sub` identifier to provide a hint to the Google Authentication Server who is asking for authentication.
- `prompt`:
- Type: `string`.
- `Optional`.
- Define the prompt the user will receive when logging into their Google account. If not sent, the user will only be prompted the first time your project requests access. <br />Choose one of the following options:
- `none`: Do not display any authentication or consent screens. Must not be specified with other values.
- `consent`: Prompt the user for consent.
- `select_account`: Prompt the user to select an account.
#### Authentication Flow
After the completion of the Google OAuth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
`googleAuth` method provides 3 set key data:
- `token`:
- Access token to make requests to the google API for retrieving user information and performing actions on their behalf.
- Type:
```
{
token: string
expires_in: number
}
```
- `granted-scopes`:
- If the `include_granted_scopes` parameter was set to `true`, you can find here the scopes for which the user has granted permissions.
- Type: `string[]`.
- `user-google`:
- User basic info retrieved from Google
- Type:
```
{
id: string
email: string
verified_email: boolean
name: string
given_name: string
family_name: string
picture: string
locale: string
}
```
To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request handler.
```ts
app.get('/google', (c) => {
const token = c.get('token')
const grantedScopes = c.get('granted-scopes')
const user = c.get('user-google')
return c.json({
token,
grantedScopes,
user,
})
})
```
#### Revoke Token
In certain use cases, you may need to programmatically revoke a user's access token. In such scenarios, you can utilize the `revokeToken` method, which accepts the `token` to be revoked as its unique parameter.
```ts
import { googleAuth, revokeToken } from 'open-auth/google'
app.post('/remove-user', async (c, next) => {
await revokeToken(USER_TOKEN)
// ...
})
```
### Facebook
```ts
import { Hono } from 'hono'
import { facebookAuth } from '@hono/oauth-providers/facebook'
const app = new Hono()
app.use(
'/facebook',
facebookAuth({
client_id: Bun.env.FACEBOOK_ID,
client_secret: Bun.env.FACEBOOK_SECRET,
scope: ['email', 'public_profile'],
fields: [
'email',
'id',
'first_name',
'last_name',
'middle_name',
'name',
'picture',
'short_name',
],
})
)
export default app
```
#### Parameters
- `client_id`:
- Type: `string`.
- `Required`.
- Your app client ID. You can find this value in the App Dashboard [Dashboard page](https://developers.facebook.com/apps). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `FACEBOOK_ID=`.
- `client_secret`:
- Type: `string`.
- `Required`.
- Your app client secret. You can find this value in the App Dashboard [Dashboard page](https://developers.facebook.com/apps). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `FACEBOOK_SECRET=`.
> Do not share your client secret to ensure the security of your app.
- `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.<br /> Review all the scopes Facebook offers for utilizing their API on the [Permissions page](https://developers.facebook.com/docs/permissions/).
> If your app is not **verified** by Facebook, the accessible scopes for your app are significantly **limited**.
- `fields`:
- Type: `string[]`.
- Fields you request from the Facebook API to be sent once the user has logged in. You can find a comprehensive reference for all the fields you can request on the [Facebook User Reference page](https://developers.facebook.com/docs/graph-api/reference/user/#fields).
#### Authentication Flow
After the completion of the Facebook OAuth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
`facebookAuth` method provides 3 set key data:
- `token`:
- Access token to make requests to the Facebook API for retrieving user information and performing actions on their behalf. It has a duration of 60 days.
- Type:
```
{
token: string
expires_in: number
}
```
- `granted-scopes`:
- If the `include_granted_scopes` parameter was set to `true`, you can find here the scopes for which the user has granted permissions.
- Type: `string[]`.
- `user-facebook`:
- User basic info retrieved from Facebook
- Type:
```
{
id: string
name: string
email: string
picture: {
data: {
height: number
is_silhouette: boolean
url: string
width: number
}
}
first_name: string
last_name: string
short_name: string
}
```
To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request handler.
```ts
app.get('/facebook', (c) => {
const token = c.get('token')
const grantedScopes = c.get('granted-scopes')
const user = c.get('user-facebook')
return c.json({
token,
grantedScopes,
user,
})
})
```
### GitHub
GitHub provides two types of Apps to utilize its API: the `GitHub App` and the `OAuth App`. To understand the differences between these apps, you can read this [article](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/deciding-when-to-build-a-github-app) from GitHub, helping you determine the type of App you should select.
#### Parameters
- `client_id`:
- Type: `string`.
- `Required`.
- `Github App` and `Oauth App`.
- Your app client ID. You can find this value in the [GitHub App settings](https://github.com/settings/apps) or the [OAuth App settings](https://github.com/settings/developers) based on your App type. <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `GITHUB_ID=`.
- `client_secret`:
- Type: `string`.
- `Required`.
- `Github App` and `Oauth App`.
- Your app client secret. You can find this value in the [GitHub App settings](https://github.com/settings/apps) or the [OAuth App settings](https://github.com/settings/developers) based on your App type. <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `GITHUB_SECRET=`.
> Do not share your client secret to ensure the security of your app.
- `scope`:
- Type: `string[]`.
- `Required`.
- `Oauth App`.
- Set of **permissions** to request the user's authorization to access your app for retrieving user information and performing actions on their behalf.<br /> Review all the scopes Github offers for utilizing their API on the [Permissions page](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps). <br />For `GitHub Apps`, you select the scopes during the App creation process or in the [settings](https://github.com/settings/apps).
- `oauthApp`:
- Type: `boolean`.
- `Required`.
- `Oauth App`.
- Set this value to `true` if your App is of the OAuth App type. Defaults to `false`.
#### Authentication Flow
After the completion of the Github Auth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
`githubAuth` method provides 4 set key data:
- `token`:
- Access token to make requests to the Github API for retrieving user information and performing actions on their behalf.
- Type:
```
{
token: string
expires_in: number // -> only available for Oauth Apps
}
```
- `refresh-token`:
- You can refresh new tokens using this token, which has a longer lifespan. Only available for Oauth Apps.
- Type:
```
{
token: string
expires_in: number
}
```
- `user-github`:
- User basic info retrieved from Github
- Type:
```
{
login: string
id: number
node_id: string
avatar_url: string
gravatar_id: string
url: string
html_url: string
followers_url: string
following_url: string
gists_url: string
starred_url: string
subscriptions_url: string
organizations_url: string
repos_url: string
events_url: string
received_events_url: string
type: string
site_admin: boolean
name: string
company: string
blog: string
location: string
email: string | null
hireable: boolean | null
bio: string
twitter_username: string
public_repos: number
public_gists: number
followers: number
following: number
created_at: string
updated_at: string
private_gists: number, // -> Github App
total_private_repos: number, // -> Github App
owned_private_repos: number, // -> Github App
disk_usage: number, // -> Github App
collaborators: number, // -> Github App
two_factor_authentication: boolean, // -> Github App
plan: {
name: string,
space: number,
collaborators: number,
private_repos: number
} // -> Github App
}
```
- `granted-scopes`:
- If the `include_granted_scopes` parameter was set to `true`, you can find here the scopes for which the user has granted permissions.
#### Github App Example
```ts
import { Hono } from 'hono'
import { githubAuth } from '@hono/oauth-providers/github'
const app = new Hono()
app.use(
'/github',
githubAuth({
client_id: Bun.env.GITHUB_ID,
client_secret: Bun.env.GITHUB_SECRET,
})
)
app.get('/github', (c) => {
const token = c.get('token')
const user = c.get('user-github')
return c.json({
token,
user,
})
})
export default app
```
#### OAuth App Example
```ts
import { Hono } from 'hono'
import { githubAuth } from '@hono/oauth-providers/github'
const app = new Hono()
app.use(
'/github',
githubAuth({
client_id: Bun.env.GITHUB_ID,
client_secret: Bun.env.GITHUB_SECRET,
scope: ['public_repo', 'read:user', 'user', 'user:email', 'user:follow'],
oauthApp: true,
})
)
app.get('/github', (c) => {
const token = c.get('token')
const refreshToken = c.get('refresh-token')
const user = c.get('user-github')
return c.json({
token,
refreshToken,
user,
})
})
export default app
```
### LinkedIn
LinkedIn provides two types of Authorization to utilize its API: the `Member Authotization` and the `Application Authorization`. To understand the differences between these authorization methods, you can read this [article](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication?context=linkedin%2Fcontext) from LinkedIn, helping you determine the type of Authorization your app should use.
#### Parameters
- `client_id`:
- Type: `string`.
- `Required`.
- `Member` and `Application` authorization.
- Your app client ID. You can find this value in the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `LINKEDIN_ID=`.
- `client_secret`:
- Type: `string`.
- `Required`.
- `Member` and `Application` authorization.
- Your app client secret. You can find this value in the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `LINKEDIN_SECRET=`.
> Do not share your client secret to ensure the security of your app.
- `scope`:
- Type: `string[]`.
- `Required`.
- `Member Authorization`.
- Set of **permissions** to request the user's authorization to access your app for retrieving user information and performing actions on their behalf.<br /> Review all the scopes LinkedIn offers for utilizing their API on the [Getting Access docs page](https://learn.microsoft.com/en-us/linkedin/shared/authentication/getting-access).
- `appAuth`: - Type: `boolean`. - `Required`. - `Application Authorization`. - Set this value to `true` if your App uses the App Authorization method. Defaults to `false`.
> To access the Application Authorization method you have to ask LinkedIn for It. Apparently you have to verify your app then ask for access.
#### Authentication Flow
After the completion of the LinkedIn Auth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
`linkedinAuth` method provides 4 set key data:
- `token`:
- Access token to make requests to the LinkedIn API for retrieving user information and performing actions on their behalf.
- Type:
```
{
token: string
expires_in: number
}
```
- `refresh-token`:
- You can refresh new tokens using this token, which has a longer lifespan. Only available for Member Authorization.
- Type:
```
{
token: string
expires_in: number
}
```
- `user-linkedin`:
- User basic info retrieved from LinkedIn.
- Type:
```
{
sub: string
email_verified: boolean
name: string
locale: {
country: string
language: string
},
given_name: string
family_name: string
email: string
picture: string
}
```
> Only available for Member Authorization.
- `granted-scopes`:
- If the `include_granted_scopes` parameter was set to `true`, you can find here the scopes for which the user has granted permissions.
#### Member Authentication Example
```ts
import { Hono } from 'hono'
import { linkedinAuth } from '@hono/oauth-providers/linkedin'
const app = new Hono()
app.use(
'/linkedin',
linkedinAuth({
client_id: Bun.env.LINKEDIN_ID,
client_secret: Bun.env.LINKEDIN_SECRET,
scope: ['email', 'openid', 'profile'],
})
)
app.get('/linkedin', (c) => {
const token = c.get('token')
const user = c.get('user-linkedin')
return c.json({
token,
user,
})
})
export default app
```
#### Application Example
```ts
import { Hono } from 'hono'
import { linkedinAuth } from '@hono/oauth-providers/linkedin'
const app = new Hono()
app.use(
'/linkedin',
linkedinAuth({
client_id: Bun.env.LINKEDIN_ID,
client_secret: Bun.env.LINKEDIN_SECRET,
appAuth: true,
})
)
app.get('/linkedin', (c) => {
const token = c.get('token')
return c.json(token)
})
export default app
```
#### Revoke Token
In certain use cases, you may need to programmatically revoke a user's access token. In such scenarios, you can utilize the `revokeToken` method.
**Parameters**:
- `client_id`:
- `string`.
- client_secret:
- `string`.
- `refresh_token`:
- `string`.
**Return Value**:
- `token`:
- `string`.
```ts
import { linkedinAuth, refreshToken } from 'open-auth/linkedin'
app.post('linkedin/refresh-token', async (c, next) => {
const token = await refreshToken(LINKEDIN_ID, LINKEDIN_SECRET, USER_REFRESH_TOKEN)
// ...
})
```
## Author
monoald https://github.com/monoald
## License
MIT
## Contribute
If you want to add new providers, features or solve some bugs don't doubt to create an issue or make a PR.
For testing purposes run the following code in the parent folder (`middleware/`):

View File

@ -0,0 +1,7 @@
module.exports = {
...require('../../jest.config.js'),
testEnvironmentOptions: {
customExportConditions: [''],
},
modulePathIgnorePatterns: ['handlers']
}

View File

@ -0,0 +1,102 @@
{
"name": "@hono/oauth-providers",
"version": "0.0.0",
"description": "Social login for Hono JS, integrate authentication with facebook, github, google and linkedin to your projects.",
"main": "dist/index.js",
"files": [
"./dist"
],
"scripts": {
"test": "jest",
"build": "tsup && publint",
"watch": "tsup --watch",
"publint": "publint",
"release": "yarn build && yarn test && yarn publint && yarn publish"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./google": {
"import": {
"types": "./dist/providers/google/index.d.mts",
"default": "./dist/providers/google/index.mjs"
},
"require": {
"types": "./dist/providers/google/index.d.ts",
"default": "./dist/providers/google/index.js"
}
},
"./facebook": {
"import": {
"types": "./dist/providers/facebook/index.d.mts",
"default": "./dist/providers/facebook/index.mjs"
},
"require": {
"types": "./dist/providers/facebook/index.d.ts",
"default": "./dist/providers/facebook/index.js"
}
},
"./github": {
"import": {
"types": "./dist/providers/github/index.d.mts",
"default": "./dist/providers/github/index.mjs"
},
"require": {
"types": "./dist/providers/github/index.d.ts",
"default": "./dist/providers/github/index.js"
}
},
"./linkedin": {
"import": {
"types": "./dist/providers/linkedin/index.d.mts",
"default": "./dist/providers/linkedin/index.mjs"
},
"require": {
"types": "./dist/providers/linkedin/index.d.ts",
"default": "./dist/providers/linkedin/index.js"
}
}
},
"typesVersions": {
"*": {
"facebook": [
"./dist/providers/facebook/index.d.ts"
],
"github": [
"./dist/providers/github/index.d.ts"
],
"google": [
"./dist/providers/google/index.d.ts"
],
"linkedin": [
"./dist/providers/linkedin/index.d.ts"
]
}
},
"peerDependencies": {
"hono": "^3.0.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20231025.0",
"@types/jest": "^29.5.7",
"hono": "^3.9.2",
"jest": "^29.7.0",
"jest-environment-miniflare": "^2.14.1",
"msw": "^2.0.4",
"patch-package": "^8.0.0",
"ts-jest": "^29.1.1",
"tsup": "^8.0.0",
"typescript": "^5.2.2"
},
"engines": {
"node": ">=18.4.0"
}
}

View File

@ -0,0 +1 @@
export { OAuthVariables } from './types'

View File

@ -0,0 +1,114 @@
import { HTTPException } from 'hono/http-exception'
import type { Token } from '../../types'
import { toQueryParams } from '../../utils/objectToQuery'
import type {
FacebookErrorResponse,
FacebookMeResponse,
FacebookTokenResponse,
FacebookUser,
Fields,
Permissions,
} from './types'
type FacebookAuthFlow = {
client_id: string
client_secret: string
redirect_uri: string
scope: Permissions[]
fields: Fields[]
state: string
code: string | undefined
token: Token | undefined
}
export class AuthFlow {
client_id: string
client_secret: string
redirect_uri: string
scope: Permissions[]
fields: Fields[]
state: string
code: string | undefined
token: Token | undefined
user: Partial<FacebookUser> | undefined
constructor({
client_id,
client_secret,
redirect_uri,
scope,
state,
fields,
code,
token,
}: FacebookAuthFlow) {
this.client_id = client_id
this.client_secret = client_secret
this.redirect_uri = redirect_uri
this.scope = scope
this.fields = fields
this.state = state
this.code = code
this.token = token
this.user = undefined
}
redirect() {
const parsedOptions = toQueryParams({
client_id: this.client_id,
redirect_uri: this.redirect_uri,
response_type: ['code', 'granted_scopes'],
scope: this.scope,
state: this.state,
})
return `https://www.facebook.com/v18.0/dialog/oauth?${parsedOptions}`
}
private async getTokenFromCode() {
const parsedOptions = toQueryParams({
client_id: this.client_id,
redirect_uri: this.redirect_uri,
client_secret: this.client_secret,
code: this.code,
})
const url = `https://graph.facebook.com/v18.0/oauth/access_token?${parsedOptions}`
const response = (await fetch(url).then((res) => res.json())) as
| FacebookTokenResponse
| FacebookErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message })
if ('access_token' in response) {
this.token = {
token: response.access_token,
expires_in: response.expires_in,
}
}
}
private async getUserId() {
const response = (await fetch(
`https://graph.facebook.com/v18.0/me?access_token=${this.token?.token}`
).then((res) => res.json())) as FacebookMeResponse | FacebookErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message })
if ('id' in response) this.user = response
}
async getUserData() {
await this.getTokenFromCode()
await this.getUserId()
const parsedFields = this.fields.join()
const response = (await fetch(
`https://graph.facebook.com/${this.user?.id}?fields=${parsedFields}&access_token=${this.token?.token}`
).then((res) => res.json())) as FacebookUser | FacebookErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message })
if ('id' in response) this.user = response
}
}

View File

@ -0,0 +1,61 @@
import type { MiddlewareHandler } from 'hono'
import { setCookie, getCookie } from 'hono/cookie'
import { HTTPException } from 'hono/http-exception'
import { getRandomState } from '../../utils/getRandomState'
import { AuthFlow } from './authFlow'
import type { Fields, Permissions } from './types'
export function facebookAuth(options: {
scope: Permissions[]
fields: Fields[]
client_id?: string
client_secret?: string
}): MiddlewareHandler {
return async (c, next) => {
const newState = getRandomState()
// Create new Auth instance
const auth = new AuthFlow({
client_id: options.client_id || (c.env?.FACEBOOK_ID as string),
client_secret: options.client_secret || (c.env?.FACEBOOK_SECRET as string),
redirect_uri: c.req.url.split('?')[0],
scope: options.scope,
fields: options.fields,
state: newState,
code: c.req.query('code'),
token: {
token: c.req.query('access_token') as string,
expires_in: Number(c.req.query('expires_in')),
},
})
// 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)
}
}
// Redirect to login dialog
if (!auth.code) {
setCookie(c, 'state', newState, {
maxAge: 60 * 10,
httpOnly: true,
path: '/',
// secure: true,
})
return c.redirect(auth.redirect())
}
// Retrieve user data from facebook
await auth.getUserData()
// Set return info
c.set('token', auth.token)
c.set('user-facebook', auth.user)
c.set('granted-scopes', c.req.query('granted_scopes')?.split(','))
await next()
}
}

View File

@ -0,0 +1,10 @@
import type { OAuthVariables } from '../../types'
import type { FacebookUser } from './types'
export { facebookAuth } from './facebookAuth'
export * from './types'
declare module 'hono' {
interface ContextVariableMap extends OAuthVariables {
'user-facebook': Partial<FacebookUser> | undefined
}
}

View File

@ -0,0 +1,130 @@
export type Permissions =
| 'ads_management'
| 'ads_read'
| 'attribution_read'
| 'business_management'
| 'catalog_management'
| 'email'
| 'gaming_user_locale'
| 'groups_access_member_info'
| 'instagram_basic'
| 'instagram_content_publish'
| 'instagram_graph_user_media'
| 'instagram_graph_user_profile'
| 'instagram_manage_comments'
| 'instagram_manage_insights'
| 'instagram_manage_messages'
| 'instagram_shopping_tag_products'
| 'leads_retrieval'
| 'manage_fundraisers'
| 'pages_events'
| 'pages_manage_ads'
| 'pages_manage_cta'
| 'pages_manage_instant_articles'
| 'pages_manage_engagement'
| 'pages_manage_metadata'
| 'pages_manage_posts'
| 'pages_messaging'
| 'pages_read_engagement'
| 'pages_read_user_content'
| 'pages_show_list'
| 'pages_user_gender'
| 'pages_user_locale'
| 'pages_user_timezone'
| 'private_computation_access'
| 'public_profile'
| 'publish_to_groups'
| 'publish_video'
| 'read_insights'
| 'user_age_range'
| 'user_birthday'
| 'user_friends'
| 'user_gender'
| 'user_hometown'
| 'user_likes'
| 'user_link'
| 'user_location'
| 'user_messenger_contact'
| 'user_photos'
| 'user_posts'
| 'user_videos'
| 'whatsapp_business_management'
| 'whatsapp_business_messaging'
export type Fields =
| 'id'
| 'first_name'
| 'last_name'
| 'middle_name'
| 'name'
| 'name_format'
| 'picture'
| 'short_name'
| 'about'
| 'age_range'
| 'birthday'
| 'education'
| 'email'
| 'favorite_athletes'
| 'favorite_teams'
| 'gender'
| 'hometown'
| 'id_for_avatars'
| 'inspirational_people'
| 'install_type'
| 'installed'
| 'is_guest_user'
| 'languages'
| 'link'
| 'location'
| 'meeting_for'
| 'middle_name'
| 'payment_pricepoints'
| 'political'
| 'profile_pic'
| 'quotes'
| 'relationship_status'
| 'shared_login_upgrade_required_by'
| 'significant_other'
| 'sports'
| 'supports_donate_button_in_live_video'
| 'token_for_business'
| 'website'
| ''
export type FacebookErrorResponse = {
error?: {
message: string
type: string
code: number
fbtrace_id: string
}
}
export type FacebookTokenResponse = {
access_token: string
token_type: string
expires_in: number
}
export type FacebookMeResponse = {
name: string
id: string
}
export type FacebookUser = {
id: string
name: string
email: string
picture: {
data: {
height: number
is_silhouette: boolean
url: string
width: number
}
}
first_name: string
last_name: string
short_name: string
}

View File

@ -0,0 +1,105 @@
import { HTTPException } from 'hono/http-exception'
import { toQueryParams } from '../../utils/objectToQuery'
import type { GitHubErrorResponse, GitHubTokenResponse, GitHubUser, GitHubScope } from './types'
type GithubAuthFlow = {
client_id: string
client_secret: string
scope?: GitHubScope[]
state: string
oauthApp: boolean
code: string | undefined
}
type Token = {
token: string
expires_in?: number
}
export class AuthFlow {
client_id: string
client_secret: string
scope: GitHubScope[] | undefined
state: string
oauthApp: boolean
code: string | undefined
token: Token | undefined
refresh_token: Token | undefined
user: Partial<GitHubUser> | undefined
granted_scopes: string[] | undefined
constructor({ client_id, client_secret, scope, state, oauthApp, code }: GithubAuthFlow) {
this.client_id = client_id
this.client_secret = client_secret
this.scope = scope
this.state = state
this.oauthApp = oauthApp
this.code = code
this.token = undefined
this.refresh_token = undefined
this.user = undefined
this.granted_scopes = undefined
}
redirect() {
const url = 'https://github.com/login/oauth/authorize?'
if (this.oauthApp) {
const parsedScope = toQueryParams({
scope: this.scope,
state: this.state,
})
return `${url}${parsedScope}&client_id=${this.client_id}`
}
return `${url}client_id=${this.client_id}`
}
private async getTokenFromCode() {
const response = (await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
body: JSON.stringify({
client_id: this.client_id,
client_secret: this.client_secret,
code: this.code,
}),
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
}).then((res) => res.json())) as GitHubTokenResponse | GitHubErrorResponse
if ('error_description' in response)
throw new HTTPException(400, { message: response.error_description })
if ('access_token' in response) {
this.token = {
token: response.access_token,
expires_in: response.expires_in,
}
this.granted_scopes = response.scope.split(',')
if (response.refresh_token && response.refresh_token_expires_in) {
this.refresh_token = {
token: response.refresh_token,
expires_in: response.refresh_token_expires_in,
}
}
}
}
async getUserData() {
if (!this.token?.token) await this.getTokenFromCode()
const response = (await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${this.token?.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'Hono-Auth-App',
},
}).then((res) => res.json())) as GitHubUser | GitHubErrorResponse
if ('message' in response) throw new HTTPException(400, { message: response.message })
if ('id' in response) {
this.user = response
}
}
}

View File

@ -0,0 +1,57 @@
import type { MiddlewareHandler } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import { HTTPException } from 'hono/http-exception'
import { getRandomState } from '../../utils/getRandomState'
import { AuthFlow } from './authFlow'
import type { GitHubScope } from './types'
export function githubAuth(options: {
client_id?: string
client_secret?: string
scope?: GitHubScope[]
oauthApp?: boolean
}): MiddlewareHandler {
return async (c, next) => {
const newState = getRandomState()
// Create new Auth instance
const auth = new AuthFlow({
client_id: options.client_id || (c.env?.GITHUB_ID as string),
client_secret: options.client_secret || (c.env?.GITHUB_SECRET as string),
scope: options.scope,
state: newState,
oauthApp: options.oauthApp || false,
code: c.req.query('code'),
})
// 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)
}
}
// Redirect to login dialog
if (!auth.code) {
setCookie(c, 'state', newState, {
maxAge: 60 * 10,
httpOnly: true,
path: '/',
// secure: true,
})
return c.redirect(auth.redirect())
}
// Retrieve user data from github
await auth.getUserData()
// Set return info
c.set('token', auth.token)
c.set('refresh-token', auth.refresh_token)
c.set('user-github', auth.user)
c.set('granted-scopes', auth.granted_scopes)
await next()
}
}

View File

@ -0,0 +1,10 @@
import type { OAuthVariables } from '../../types'
import type { GitHubUser } from './types'
export { githubAuth } from './githubAuth'
export * from './types'
declare module 'hono' {
interface ContextVariableMap extends OAuthVariables {
'user-github': Partial<GitHubUser> | undefined
}
}

View File

@ -0,0 +1,97 @@
export type GitHubScope =
| 'repo'
| 'repo:status'
| 'repo_deployment'
| 'public_repo'
| 'repo:invite'
| 'security_events'
| 'admin:repo_hook'
| 'write:repo_hook'
| 'read:repo_hook'
| 'admin:org'
| 'write:org'
| 'read:org'
| 'admin:public_key'
| 'write:public_key'
| 'read:public_key'
| 'admin:org_hook'
| 'gist'
| 'notifications'
| 'user'
| 'read:user'
| 'user:email'
| 'user:follow'
| 'project'
| 'read:project'
| 'delete_repo'
| 'write:packages'
| 'read:packages'
| 'delete:packages'
| 'admin:gpg_key'
| 'write:gpg_key'
| 'read:gpg_key'
| 'codespace'
| 'workflow'
export type GitHubErrorResponse = {
error: string
error_description: string
message: string
documentation_url: string
}
export type GitHubTokenResponse = {
access_token: string
expires_in?: number
refresh_token?: string
refresh_token_expires_in?: number
token_type: string
scope: string
}
export type GitHubUser = {
login: string
id: number
node_id: string
avatar_url: string
gravatar_id: string
url: string
html_url: string
followers_url: string
following_url: string
gists_url: string
starred_url: string
subscriptions_url: string
organizations_url: string
repos_url: string
events_url: string
received_events_url: string
type: string
site_admin: boolean
name: string
company: string
blog: string
location: string
email: string | null
hireable: boolean | null
bio: string
twitter_username: string
public_repos: number
public_gists: number
followers: number
following: number
created_at: string
updated_at: string
private_gists: number
total_private_repos: number
owned_private_repos: number
disk_usage: number
collaborators: number
two_factor_authentication: boolean
plan: {
name: string
space: number
collaborators: number
private_repos: number
}
}

View File

@ -0,0 +1,119 @@
import { HTTPException } from 'hono/http-exception'
import type { Token } from '../../types'
import { toQueryParams } from '../../utils/objectToQuery'
import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from './types'
type GoogleAuthFlow = {
client_id: string
client_secret: string
redirect_uri: string
code: string | undefined
token: Token | undefined
scope: string[]
state?: string
login_hint?: string
prompt?: 'none' | 'consent' | 'select_account'
}
export class AuthFlow {
client_id: string
client_secret: string
redirect_uri: string
code: string | undefined
token: Token | undefined
scope: string[]
state: string | undefined
login_hint: string | undefined
prompt: 'none' | 'consent' | 'select_account' | undefined
user: Partial<GoogleUser> | undefined
granted_scopes: string[] | undefined
constructor({
client_id,
client_secret,
redirect_uri,
login_hint,
prompt,
scope,
state,
code,
token,
}: GoogleAuthFlow) {
this.client_id = client_id
this.client_secret = client_secret
this.redirect_uri = redirect_uri
this.login_hint = login_hint
this.prompt = prompt
this.scope = scope
this.state = state
this.code = code
this.token = token
this.user = undefined
this.granted_scopes = undefined
if (
this.client_id === undefined ||
this.client_secret === undefined ||
this.scope === undefined
) {
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://accounts.google.com/o/oauth2/v2/auth?${parsedOptions}`
}
async getTokenFromCode() {
const response = (await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
clientId: this.client_id,
clientSecret: this.client_secret,
redirect_uri: this.redirect_uri,
code: this.code,
grant_type: 'authorization_code',
}),
}).then((res) => res.json())) as GoogleTokenResponse | GoogleErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description })
if ('access_token' in response) {
this.token = {
token: response.access_token,
expires_in: response.expires_in,
}
this.granted_scopes = response.scope.split(' ')
}
}
async getUserData() {
await this.getTokenFromCode()
const response = (await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
authorization: `Bearer ${this.token?.token}`,
},
}).then((res) => res.json())) as GoogleUser | GoogleErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message })
if ('id' in response) this.user = response
}
}

View File

@ -0,0 +1,62 @@
import type { MiddlewareHandler } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import { HTTPException } from 'hono/http-exception'
import { getRandomState } from '../../utils/getRandomState'
import { AuthFlow } from './authFlow'
export function googleAuth(options: {
scope: string[]
login_hint?: string
prompt?: 'none' | 'consent' | 'select_account'
client_id?: string
client_secret?: string
}): MiddlewareHandler {
return async (c, next) => {
const newState = getRandomState()
// Create new Auth instance
const auth = new AuthFlow({
client_id: options.client_id || (c.env?.GOOGLE_ID as string),
client_secret: options.client_secret || (c.env?.GOOGLE_SECRET as string),
redirect_uri: c.req.url.split('?')[0],
login_hint: options.login_hint,
prompt: options.prompt,
scope: options.scope,
state: newState,
code: c.req.query('code'),
token: {
token: c.req.query('access_token') as string,
expires_in: Number(c.req.query('expires-in')) as number,
},
})
// 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)
}
}
// Redirect to login dialog
if (!auth.code) {
setCookie(c, 'state', newState, {
maxAge: 60 * 10,
httpOnly: true,
path: '/',
// secure: true,
})
return c.redirect(auth.redirect())
}
// Retrieve user data from google
await auth.getUserData()
// Set return info
c.set('token', auth.token)
c.set('user-google', auth.user)
c.set('granted-scopes', auth.granted_scopes)
await next()
}
}

View File

@ -0,0 +1,11 @@
export { googleAuth } from './googleAuth'
export { revokeToken } from './revokeToken'
export * from './types'
import type { OAuthVariables } from '../../types'
import type { GoogleUser } from './types'
declare module 'hono' {
interface ContextVariableMap extends OAuthVariables {
'user-google': Partial<GoogleUser> | undefined
}
}

View File

@ -0,0 +1,8 @@
export async function revokeToken(token: string) {
const response = await fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, {
method: 'POST',
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
})
return response.status === 200
}

View File

@ -0,0 +1,38 @@
export type GoogleErrorResponse = {
error?: {
code: number
message: string
status: string
}
error_description: string
}
export type GoogleTokenResponse = {
access_token: string
expires_in: number
scope: string
token_type: string
id_token: string
}
export type GoogleTokenInfoResponse = {
issued_to: string
audience: string
user_id: string
scope: string
expires_in: number
email: string
verified_email: boolean
access_type: string
}
export type GoogleUser = {
id: string
email: string
verified_email: boolean
name: string
given_name: string
family_name: string
picture: string
locale: string
}

View File

@ -0,0 +1,136 @@
import { HTTPException } from 'hono/http-exception'
import type { Token } from '../../types'
import { toQueryParams } from '../../utils/objectToQuery'
import type {
LinkedInErrorResponse,
LinkedInTokenResponse,
LinkedInUser,
LinkedInScope,
} from './types'
export type LinkedInAuthFlow = {
client_id: string
client_secret: string
redirect_uri: string
scope: LinkedInScope[] | undefined
state: string
appAuth: boolean
code: string | undefined
}
export class AuthFlow {
client_id: string
client_secret: string
redirect_uri: string
scope: LinkedInScope[] | undefined
state: string
code: string | undefined
token: Token | undefined
refresh_token: Token | undefined
user: Partial<LinkedInUser> | undefined
granted_scopes: string[] | undefined
constructor({
client_id,
client_secret,
redirect_uri,
scope,
state,
appAuth,
code,
}: LinkedInAuthFlow) {
this.client_id = client_id
this.client_secret = client_secret
;(this.redirect_uri = redirect_uri), (this.scope = scope)
this.state = state
this.code = appAuth ? '' : code
this.token = undefined
this.refresh_token = undefined
this.user = undefined
this.granted_scopes = undefined
}
redirect() {
const params = toQueryParams({
response_type: 'code',
client_id: this.client_id,
redirect_uri: this.redirect_uri,
scope: this.scope?.join(' ') || undefined,
state: this.state,
})
return `https://www.linkedin.com/oauth/v2/authorization?${params}`
}
private async getTokenFromCode() {
const params = toQueryParams({
grant_type: 'authorization_code',
code: this.code,
client_id: this.client_id,
client_secret: this.client_secret,
redirect_uri: this.redirect_uri,
})
const response = (await fetch(`https://www.linkedin.com/oauth/v2/accessToken?${params}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}).then((res) => res.json())) as LinkedInTokenResponse | LinkedInErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description })
if ('access_token' in response) {
this.token = {
token: response.access_token,
expires_in: response.expires_in,
}
this.refresh_token = {
token: response.refresh_token,
expires_in: response.refresh_token_expires_in,
}
this.granted_scopes = response.scope?.split(',')
}
}
async getUserData() {
if (!this.token) {
await this.getTokenFromCode()
}
const response = (await fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${this.token?.token}`,
},
}).then((res) => res.json())) as LinkedInUser | LinkedInErrorResponse
if ('message' in response) throw new HTTPException(400, { message: response.message })
if ('sub' in response) this.user = response
}
async getAppToken() {
const params = toQueryParams({
grant_type: 'client_credentials',
client_id: this.client_id,
client_secret: this.client_secret,
})
const response = (await fetch(`https://www.linkedin.com/oauth/v2/accessToken?${params}`).then(
(res) => res.json()
)) as LinkedInTokenResponse | LinkedInErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description })
if ('access_token' in response) {
this.token = {
token: response.access_token,
expires_in: response.expires_in,
}
this.granted_scopes = response.scope?.split(',')
}
}
}

View File

@ -0,0 +1,11 @@
import type { OAuthVariables } from '../../types'
import type { LinkedInUser } from './types'
export { linkedinAuth } from './linkedinAuth'
export { refreshToken } from './refreshToken'
export * from './types'
declare module 'hono' {
interface ContextVariableMap extends OAuthVariables {
'user-linkedin': Partial<LinkedInUser> | undefined
}
}

View File

@ -0,0 +1,61 @@
import type { MiddlewareHandler } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import { HTTPException } from 'hono/http-exception'
import { getRandomState } from '../../utils/getRandomState'
import { AuthFlow } from './authFlow'
import type { LinkedInScope } from './types'
export function linkedinAuth(options: {
client_id?: string
client_secret?: string
scope?: LinkedInScope[]
appAuth?: boolean
}): MiddlewareHandler {
return async (c, next) => {
const newState = getRandomState()
// Create new Auth instance
const auth = new AuthFlow({
client_id: options.client_id || (c.env?.LINKEDIN_ID as string),
client_secret: options.client_secret || (c.env?.LINKEDIN_SECRET as string),
redirect_uri: c.req.url.split('?')[0],
scope: options.scope,
state: newState,
appAuth: options.appAuth || false,
code: c.req.query('code'),
})
// 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)
}
}
// Redirect to login dialog
if (!auth.code && !options.appAuth) {
setCookie(c, 'state', newState, {
maxAge: 60 * 10,
httpOnly: true,
path: '/',
// secure: true,
})
return c.redirect(auth.redirect())
}
if (options.appAuth) {
await auth.getAppToken()
} else {
await auth.getUserData()
}
// Set return info
c.set('token', auth.token)
c.set('refresh-token', auth.refresh_token)
c.set('user-linkedin', auth.user)
c.set('granted-scopes', auth.granted_scopes)
await next()
}
}

View File

@ -0,0 +1,28 @@
import { HTTPException } from 'hono/http-exception'
import { toQueryParams } from '../../utils/objectToQuery'
import type { LinkedInErrorResponse, LinkedInTokenResponse } from './types'
export async function refreshToken(
client_id: string,
client_secret: string,
refresh_token: string
): Promise<LinkedInTokenResponse> {
const params = toQueryParams({
grant_type: 'refresh_token',
refresh_token: refresh_token,
client_id: client_id,
client_secret: client_secret,
})
const response = (await fetch(`POST https://www.linkedin.com/oauth/v2/accessToken?${params}`, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}).then((res) => res.json())) as LinkedInTokenResponse | LinkedInErrorResponse
if ('error' in response) {
throw new HTTPException(400, { message: response.error })
}
return response
}

View File

@ -0,0 +1,51 @@
export type LinkedInScope =
| 'profile'
| 'email'
| 'openid'
| 'w_member_social'
| 'rw_organization_admin'
| 'r_organization_admin'
| 'w_organization_social'
| 'r_organization_social'
| 'w_member_social'
| 'rw_ads'
| 'r_ads'
| 'r_ads_reporting'
| 'r_1st_connections_size'
| 'r_basicprofile'
| 'r_marketing_leadgen_automation'
| 'rw_dmp_segments'
| 'r_sales_nav_analytics'
| 'r_sales_nav_display'
| 'r_sales_nav_validation'
| 'r_sales_nav_profiles'
| 'r_compliance'
| 'w_compliance'
export type LinkedInErrorResponse = {
error: string
error_description: string
message: string
}
export type LinkedInTokenResponse = {
access_token: string
expires_in: number
refresh_token: string
refresh_token_expires_in: number
scope?: string
}
export type LinkedInUser = {
sub: string
email_verified: boolean
name: string
locale: {
country: string
language: string
}
given_name: string
family_name: string
email: string
picture: string
}

View File

@ -0,0 +1,10 @@
export type OAuthVariables = {
token: Token | undefined
'refresh-token': Token | undefined
'granted-scopes': string[] | undefined
}
export type Token = {
token: string
expires_in: number
}

View File

@ -0,0 +1,7 @@
const rand = () => {
return Math.random().toString(36).substr(2)
}
export function getRandomState() {
return `${rand()}-${rand()}-${rand()}`
}

View File

@ -0,0 +1,12 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function toQueryParams(params: { [key: string]: any }): string {
const elements = Object.keys(params)
elements.forEach((element) => {
if (params[element] === undefined) {
delete params[element]
}
})
return new URLSearchParams(params).toString()
}

View File

@ -0,0 +1,264 @@
import type { DefaultBodyType, StrictResponse } from 'msw'
import { HttpResponse, http } from 'msw'
import type {
FacebookErrorResponse,
FacebookTokenResponse,
FacebookUser,
} from '../src/providers/facebook'
import type { GitHubErrorResponse, GitHubTokenResponse } from '../src/providers/github'
import type {
GoogleErrorResponse,
GoogleTokenResponse,
GoogleUser,
} from '../src/providers/google/types'
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
export const handlers = [
// Google
http.post(
'https://oauth2.googleapis.com/token',
async ({
request,
}): Promise<StrictResponse<Partial<GoogleTokenResponse> | GoogleErrorResponse>> => {
const body = (await request.json()) as Promise<DefaultBodyType> & { code: string }
if (body.code === dummyCode) {
return HttpResponse.json(dummyToken)
}
return HttpResponse.json(googleCodeError)
}
),
http.get(
'https://www.googleapis.com/oauth2/v2/userinfo',
async ({ request }): Promise<StrictResponse<Partial<GoogleUser> | GoogleErrorResponse>> => {
const authorization = request.headers.get('authorization')
if (authorization === `Bearer ${dummyToken.access_token}`) {
return HttpResponse.json(googleUser)
}
return HttpResponse.json(googleTokenError)
}
),
http.get('https://www.googleapis.com/oauth2/v1/tokeninfo', () =>
HttpResponse.json(googleTokenInfo)
),
// Facebook
http.get(
'https://graph.facebook.com/v18.0/oauth/access_token',
async ({
request,
}): Promise<StrictResponse<Partial<FacebookTokenResponse> | FacebookErrorResponse>> => {
const code = new URLSearchParams(request.url).get('code')
if (dummyCode === code) {
return HttpResponse.json(dummyToken)
}
return HttpResponse.json(facebookCodeError)
}
),
http.get('https://graph.facebook.com/v18.0/me', () => HttpResponse.json(facebookBasicInfo)),
http.get(
'https://graph.facebook.com/1abc345-75uyut',
async ({ request }): Promise<StrictResponse<Partial<FacebookUser> | FacebookErrorResponse>> => {
const token = new URLSearchParams(request.url).get('access_token')
if (token === dummyToken.access_token) {
return HttpResponse.json(facebookUser)
}
return HttpResponse.json(facebookTokenError)
}
),
// Github
http.post(
'https://github.com/login/oauth/access_token',
async ({
request,
}): Promise<StrictResponse<Partial<GitHubTokenResponse | GitHubErrorResponse>>> => {
const body = (await request.json()) as Promise<DefaultBodyType> & { code: string }
if (body.code === dummyCode) {
return HttpResponse.json(githubToken)
}
return HttpResponse.json(githubCodeError)
}
),
http.get('https://api.github.com/user', () => HttpResponse.json(githubUser)),
// LinkedIn
http.post(
'https://www.linkedin.com/oauth/v2/accessToken',
async ({
request,
}): Promise<StrictResponse<Partial<LinkedInTokenResponse> | LinkedInErrorResponse>> => {
const code = new URLSearchParams(request.url).get('code')
if (code === dummyCode) {
return HttpResponse.json(linkedInToken)
}
return HttpResponse.json(linkedInCodeError)
}
),
http.get('https://api.linkedin.com/v2/userinfo', () => HttpResponse.json(linkedInUser)),
]
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
export const dummyToken = {
access_token: '15d42a4d-1948-4de4-ba78-b8a893feaf45',
expires_in: 60000,
scope: 'openid email profile',
}
export const googleUser = {
id: '1abc345-75uyut',
email: 'example@email.com',
verified_email: true,
name: 'Carlos Aldazosa',
given_name: 'Carlos',
family_name: 'Aldazosa',
picture: 'https://www.severnedgevets.co.uk/sites/default/files/guides/kitten.png',
locale: 'es-419',
}
export const googleCodeError = {
error: {
code: 401,
message: 'Invalid code.',
status: '401',
error: 'code_invalid',
},
error_description: 'Invalid code.',
}
export const googleTokenError = {
error: {
code: 401,
message: 'Invalid token.',
status: '401',
error: 'token_invalid',
},
error_description: 'Invalid token.',
}
const googleTokenInfo = {
issued_to: 'hyr97.457_e5gh4',
audience: 'hyr97.457_e5gh4google.com',
user_id: 'dummy-id',
scope: 'openid email profile',
expires_in: 60000,
email: 'example@email.com',
verified_email: true,
access_type: 'user',
}
export const facebookUser = {
id: '1abc345-75uyut',
name: 'Carlos Aldazosa',
email: 'example@email.com',
picture: {
data: {
height: 50,
width: 50,
is_silhouette: true,
url: 'https://www.severnedgevets.co.uk/sites/default/files/guides/kitten.png',
},
},
first_name: 'Carlos',
last_name: 'Aldazosa',
short_name: 'Carlos',
}
export const facebookCodeError = {
error: {
message: 'Invalid Code.',
type: 'Invalid',
code: 401,
fbtrace_id: 'jujublabla',
},
}
export const facebookTokenError = {
error: {
message: 'Invalid Token.',
type: 'Invalid',
code: 401,
fbtrace_id: 'jujublabla',
},
}
const facebookBasicInfo = {
id: '1abc345-75uyut',
name: 'Carlos Aldazosa',
}
export const githubToken = {
access_token: '15d42a4d-1948-4de4-ba78-b8a893feaf45',
expires_in: 60000,
scope: 'public_repo,user',
refresh_token: 't4589fh-9gj3g93-34f5t64n',
refresh_token_expires_in: 6000000,
token_type: 'bearer',
}
export const githubUser = {
login: 'monoald',
id: 9876543210,
node_id: 'HFGJ$FEF598',
avatar_url: 'https://avatars.githubusercontent.com/u/userid',
gravatar_id: '',
url: 'https://api.github.com/users',
html_url: 'https://github.com/monoald',
followers_url: 'https://api.github.com/users/user/followers',
following_url: 'https://api.github.com/users/user/following{/other_user}',
gists_url: 'https://api.github.com/users/user/gists{/gist_id}',
starred_url: 'https://api.github.com/users/user/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/user/subscriptions',
organizations_url: 'https://api.github.com/users/user/orgs',
repos_url: 'https://api.github.com/users/user/repos',
events_url: 'https://api.github.com/users/user/events{/privacy}',
received_events_url: 'https://api.github.com/users/user/received_events',
type: 'User',
site_admin: false,
name: 'Carlos Aldazosa',
company: '@rvesoftware',
blog: 'https://monoald.github.io/',
location: 'Knowhere',
email: null,
hireable: null,
bio: 'BIO description',
twitter_username: 'monoald',
public_repos: 0,
public_gists: 0,
followers: 0,
following: 0,
created_at: '2023-11-07T13:11:55Z',
updated_at: '2023-11-07T13:11:56Z',
private_gists: 0,
total_private_repos: 0,
owned_private_repos: 0,
disk_usage: 100000,
collaborators: 0,
two_factor_authentication: false,
plan: {
name: 'free',
space: 100000000,
collaborators: 0,
private_repos: 10000,
},
}
export const githubCodeError = {
error_description: 'Invalid Code.',
}
export const linkedInToken = {
access_token: '15d42a4d-1948-4de4-ba78-b8a893feaf45',
expires_in: 60000,
scope: 'email,openid,profile',
refresh_token: 't4589fh-9gj3g93-34f5t64n',
refresh_token_expires_in: 6000000,
token_type: 'bearer',
}
export const linkedInCodeError = {
error_description: 'The Code you send is invalid.',
error: 'The Code you send is invalid.',
message: 'The Code you send is invalid.',
}
export const linkedInUser = {
sub: '452FET361006',
email_verified: true,
name: 'Carlos Aldazosa',
locale: {
country: 'US',
language: 'en',
},
given_name: 'Carlos',
family_name: 'Aldazosa',
email: 'example@email.com',
picture: 'https://www.severnedgevets.co.uk/sites/default/files/guides/kitten.png',
}

View File

@ -0,0 +1,361 @@
import { Hono } from 'hono'
import { setupServer } from 'msw/node'
import { facebookAuth } from '../src/providers/facebook'
import type { FacebookUser } from '../src/providers/facebook'
import { githubAuth } from '../src/providers/github'
import type { GitHubUser } from '../src/providers/github'
import { googleAuth } from '../src/providers/google'
import type { GoogleUser } from '../src/providers/google'
import { linkedinAuth } from '../src/providers/linkedin'
import type { LinkedInUser } from '../src/providers/linkedin'
import type { Token } from '../src/types'
import {
dummyToken,
googleUser,
handlers,
facebookUser,
githubUser,
dummyCode,
googleCodeError,
facebookCodeError,
githubToken,
githubCodeError,
linkedInCodeError,
linkedInUser,
linkedInToken,
} from './handlers'
const server = setupServer(...handlers)
server.listen()
const client_id = '1jsdsldjkssd-4343dsasdsd34ghhn4-dummyid'
const client_secret = 'SDJS943hS_jj45dummysecret'
describe('OAuth Middleware', () => {
const app = new Hono()
app.use(
'/google',
googleAuth({
client_id,
client_secret,
scope: ['openid', 'email', 'profile'],
})
)
app.get('/google', (c) => {
const user = c.get('user-google')
const token = c.get('token')
const grantedScopes = c.get('granted-scopes')
return c.json({
user,
token,
grantedScopes,
})
})
app.use(
'/facebook',
facebookAuth({
client_id,
client_secret,
scope: ['email', 'public_profile'],
fields: [
'email',
'id',
'first_name',
'last_name',
'middle_name',
'name',
'picture',
'short_name',
],
})
)
app.get('/facebook', (c) => {
const user = c.get('user-facebook')
const token = c.get('token')
const grantedScopes = c.get('granted-scopes')
return c.json({
user,
token,
grantedScopes,
})
})
app.use(
'/github/app',
githubAuth({
client_id,
client_secret,
})
)
app.get('/github/app', (c) => {
const token = c.get('token')
const refreshToken = c.get('refresh-token')
const user = c.get('user-github')
const grantedScopes = c.get('granted-scopes')
return c.json({
token,
refreshToken,
user,
grantedScopes,
})
})
app.use(
'/github/oauth-app',
githubAuth({
client_id,
client_secret,
scope: ['public_repo', 'read:user', 'user', 'user:email', 'user:follow'],
oauthApp: true,
})
)
app.get('/github/oauth-app', (c) => {
const token = c.get('token')
const user = c.get('user-github')
const grantedScopes = c.get('granted-scopes')
return c.json({
user,
token,
grantedScopes,
})
})
app.use(
'linkedin',
linkedinAuth({
client_id,
client_secret,
scope: ['email', 'openid', 'profile'],
})
)
app.get('linkedin', (c) => {
const token = c.get('token')
const refreshToken = c.get('refresh-token')
const user = c.get('user-linkedin')
const grantedScopes = c.get('granted-scopes')
return c.json({
token,
refreshToken,
grantedScopes,
user,
})
})
beforeAll(() => {
server.listen()
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
describe('googleAuth middleware', () => {
it('Should redirect', async () => {
const res = await app.request('/google')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
it('Prevent CSRF attack', async () => {
const res = await app.request(`/google?code=${dummyCode}&state=malware-state`)
expect(res).not.toBeNull()
expect(res.status).toBe(401)
})
it('Should throw error for invalide code', async () => {
const res = await app.request('/google?code=9348ffdsd-sdsdbad-code')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(googleCodeError.error.message)
})
it('Should work with received code', async () => {
const res = await app.request(`/google?code=${dummyCode}`)
const response = (await res.json()) as {
token: Token
user: GoogleUser
grantedScopes: string[]
}
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(response.user).toEqual(googleUser)
expect(response.grantedScopes).toEqual(dummyToken.scope.split(' '))
expect(response.token).toEqual({
token: dummyToken.access_token,
expires_in: dummyToken.expires_in,
})
})
})
describe('facebookAuth middleware', () => {
it('Should redirect', async () => {
const res = await app.request('/facebook')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
it('Prevent CSRF attack', async () => {
const res = await app.request(`/facebook?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('/facebook?code=9348ffdsd-sdsdbad-code')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(facebookCodeError.error.message)
})
it('Should work with received code', async () => {
const res = await app.request(
`/facebook?code=${dummyCode}&granted_scopes=email%2Cpublic_profile`
)
const response = (await res.json()) as {
token: Token
user: FacebookUser
grantedScopes: string[]
}
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(response.user).toEqual(facebookUser)
expect(response.grantedScopes).toEqual(['email', 'public_profile'])
expect(response.token).toEqual({
token: dummyToken.access_token,
expires_in: dummyToken.expires_in,
})
})
})
describe('githubAuth middleware', () => {
describe('Github with Github App', () => {
it('Should redirect', async () => {
const res = await app.request('/github/app')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
it('Should throw error for invalide code', async () => {
const res = await app.request('/github/app?code=9348ffdsd-sdsdbad-code')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(githubCodeError.error_description)
})
it('Should work with received code', async () => {
const res = await app.request(`/github/app?code=${dummyCode}`)
const response = (await res.json()) as {
token: Token
refreshToken: Token
user: GitHubUser
grantedScopes: string[]
}
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(response.user).toEqual(githubUser)
expect(response.grantedScopes).toEqual(['public_repo', 'user'])
expect(response.token).toEqual({
token: githubToken.access_token,
expires_in: githubToken.expires_in,
})
expect(response.refreshToken).toEqual({
token: githubToken.refresh_token,
expires_in: githubToken.refresh_token_expires_in,
})
})
})
describe('Github with OAuth App', () => {
it('Should redirect', async () => {
const res = await app.request('/github/oauth-app')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
it('Should throw error for invalide code', async () => {
const res = await app.request('/github/oauth-app?code=9348ffdsd-sdsdbad-code')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(githubCodeError.error_description)
})
it('Should work with received code', async () => {
const res = await app.request(`/github/oauth-app?code=${dummyCode}`)
const response = (await res.json()) as {
token: Token
user: GitHubUser
grantedScopes: string[]
}
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(response.user).toEqual(githubUser)
expect(response.grantedScopes).toEqual(['public_repo', 'user'])
expect(response.token).toEqual({
token: githubToken.access_token,
expires_in: githubToken.expires_in,
})
})
})
})
describe('linkedinAuth middleware', () => {
it('Should redirect', async () => {
const res = await app.request('/linkedin')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
it('Should throw error for invalide code', async () => {
const res = await app.request('/linkedin?code=9348ffdsd-sdsdbad-code')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(linkedInCodeError.error)
})
it('Should work with received code', async () => {
const res = await app.request(`/linkedin?code=${dummyCode}`)
const response = (await res.json()) as {
token: Token
refreshToken: Token
user: LinkedInUser
grantedScopes: string[]
}
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(response.user).toEqual(linkedInUser)
expect(response.grantedScopes).toEqual(['email', 'openid', 'profile'])
expect(response.token).toEqual({
token: linkedInToken.access_token,
expires_in: linkedInToken.expires_in,
})
expect(response.refreshToken).toEqual({
token: linkedInToken.refresh_token,
expires_in: linkedInToken.refresh_token_expires_in,
})
})
})
})

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
},
"include": [
"src/**/*.ts"
],
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts', 'src/providers/**/index.ts', 'src/providers/**/types.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
clean: true,
})

1365
yarn.lock

File diff suppressed because it is too large Load Diff