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
parent
f14f0c8cf5
commit
d2696c46ba
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/oauth-providers': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add oauth-providers middleware
|
|
@ -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/`):
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = {
|
||||||
|
...require('../../jest.config.js'),
|
||||||
|
testEnvironmentOptions: {
|
||||||
|
customExportConditions: [''],
|
||||||
|
},
|
||||||
|
modulePathIgnorePatterns: ['handlers']
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { OAuthVariables } from './types'
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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(',')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
const rand = () => {
|
||||||
|
return Math.random().toString(36).substr(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRandomState() {
|
||||||
|
return `${rand()}-${rand()}-${rand()}`
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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',
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
Loading…
Reference in New Issue