feat(oauth-providers): Discord (#342)
parent
0d7244b5bb
commit
8841b6427d
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/oauth-providers': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Discord provider
|
|
@ -105,6 +105,7 @@ export default app
|
||||||
- Type: `string`.
|
- Type: `string`.
|
||||||
- `Required`.
|
- `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=`.
|
- 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=`.
|
||||||
|
> [!CAUTION]
|
||||||
> Do not share your client secret to ensure the security of your app.
|
> Do not share your client secret to ensure the security of your app.
|
||||||
- `scope`:
|
- `scope`:
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
|
@ -227,6 +228,7 @@ export default app
|
||||||
- Type: `string`.
|
- Type: `string`.
|
||||||
- `Required`.
|
- `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=`.
|
- 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=`.
|
||||||
|
> [!CAUTION]
|
||||||
> Do not share your client secret to ensure the security of your app.
|
> Do not share your client secret to ensure the security of your app.
|
||||||
- `scope`:
|
- `scope`:
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
|
@ -309,6 +311,7 @@ GitHub provides two types of Apps to utilize its API: the `GitHub App` and the `
|
||||||
- `Required`.
|
- `Required`.
|
||||||
- `Github App` and `Oauth App`.
|
- `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=`.
|
- 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=`.
|
||||||
|
> [!CAUTION]
|
||||||
> Do not share your client secret to ensure the security of your app.
|
> Do not share your client secret to ensure the security of your app.
|
||||||
- `scope`:
|
- `scope`:
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
|
@ -477,6 +480,7 @@ LinkedIn provides two types of Authorization to utilize its API: the `Member Aut
|
||||||
- `Required`.
|
- `Required`.
|
||||||
- `Member` and `Application` authorization.
|
- `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=`.
|
- 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=`.
|
||||||
|
> [!CAUTION]
|
||||||
> Do not share your client secret to ensure the security of your app.
|
> Do not share your client secret to ensure the security of your app.
|
||||||
- `scope`:
|
- `scope`:
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
|
@ -630,10 +634,7 @@ app.use(
|
||||||
client_id: Bun.env.X_ID,
|
client_id: Bun.env.X_ID,
|
||||||
client_secret: Bun.env.X_SECRET,
|
client_secret: Bun.env.X_SECRET,
|
||||||
scope: ['tweet.read', 'users.read', 'offline.access'],
|
scope: ['tweet.read', 'users.read', 'offline.access'],
|
||||||
fields: [
|
fields: ['profile_image_url', 'url'],
|
||||||
'profile_image_url',
|
|
||||||
'url',
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -649,16 +650,17 @@ export default app
|
||||||
- `client_secret`:
|
- `client_secret`:
|
||||||
- Type: `string`.
|
- Type: `string`.
|
||||||
- `Required`.
|
- `Required`.
|
||||||
- Your app client secret. You can find this value in the [Developer Portal](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 `X_SECRET=`.
|
- Your app client secret. You can find this value in the [Developer Portal](https://developer.twitter.com/en/portal/dashboard). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `X_SECRET=`.
|
||||||
|
> [!CAUTION]
|
||||||
> Do not share your client secret to ensure the security of your app.
|
> Do not share your client secret to ensure the security of your app.
|
||||||
- `scope`:
|
- `scope`:
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
- `Required`.
|
- `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 X(Twitter) offers for utilizing their API on the [Documentation](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code). <br />If not sent the default fields x set are `id`, `name` and `username.`
|
- 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 X(Twitter) offers for utilizing their API on the [Documentation](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code). <br />If not sent the default fields x set are `id`, `name` and `username.`
|
||||||
- `fields`:
|
- `fields`:
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
- `Optional`.
|
- `Optional`.
|
||||||
- Set of **fields** of the user information that can be retreived from X. Check All the fields available on the [get user me reference](https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me).
|
- Set of **fields** of the user information that can be retreived from X. Check All the fields available on the [get user me reference](https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me).
|
||||||
|
|
||||||
#### Authentication Flow
|
#### Authentication Flow
|
||||||
|
|
||||||
|
@ -688,45 +690,45 @@ After the completion of the X OAuth flow, essential data has been prepared for u
|
||||||
- Scopes for which the user has granted permissions.
|
- Scopes for which the user has granted permissions.
|
||||||
- Type: `string[]`.
|
- Type: `string[]`.
|
||||||
- `user-x`:
|
- `user-x`:
|
||||||
- User basic info retrieved from Google
|
- User basic info retrieved from X
|
||||||
- Type:
|
- Type:
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
created_at: string
|
created_at: string
|
||||||
description: string
|
description: string
|
||||||
entities: {
|
entities: {
|
||||||
url: {
|
url: {
|
||||||
urls: {
|
urls: {
|
||||||
start: number
|
start: number
|
||||||
end: number
|
end: number
|
||||||
url: string
|
url: string
|
||||||
expanded_url: string
|
expanded_url: string
|
||||||
display_url: string
|
display_url: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
id: string
|
id: string
|
||||||
location: string
|
location: string
|
||||||
most_recent_tweet_id: string
|
most_recent_tweet_id: string
|
||||||
name: string
|
name: string
|
||||||
profile_image_url: string
|
profile_image_url: string
|
||||||
protected: boolean
|
protected: boolean
|
||||||
public_metrics: {
|
public_metrics: {
|
||||||
followers_count: number
|
followers_count: number
|
||||||
following_count: number
|
following_count: number
|
||||||
tweet_count: number
|
tweet_count: number
|
||||||
listed_count: number
|
listed_count: number
|
||||||
like_count: number
|
like_count: number
|
||||||
}
|
}
|
||||||
url: string
|
url: string
|
||||||
username: string
|
username: string
|
||||||
verified_type: string
|
verified_type: string
|
||||||
verified: boolean
|
verified: boolean
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> If you want to receive the **refresh token** you must add the `offline.access` in the scopes parameter.
|
> If you want to receive the **refresh token** you must add the `offline.access` in the scopes parameter.
|
||||||
To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request handler.
|
> To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request handler.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
app.get('/x', (c) => {
|
app.get('/x', (c) => {
|
||||||
|
@ -776,6 +778,152 @@ app.post('/remove-user', async (c, next) => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Discord
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { discordAuth } from '@hono/oauth-providers/discord'
|
||||||
|
|
||||||
|
const app = new Hono()
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
'/discord',
|
||||||
|
discordAuth({
|
||||||
|
client_id: Bun.env.DISCORD_ID,
|
||||||
|
client_secret: Bun.env.DISCORD_SECRET,
|
||||||
|
scope: ['identify', 'email'],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export default app
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
- `client_id`:
|
||||||
|
- Type: `string`.
|
||||||
|
- `Required`.
|
||||||
|
- Your app client ID. You can find this value in the [Developer Portal](https://discord.com/developers/applications). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `DISCORD_ID=`.
|
||||||
|
- `client_secret`:
|
||||||
|
- Type: `string`.
|
||||||
|
- `Required`.
|
||||||
|
- Your app client secret. You can find this value in the [Developer Portal](https://discord.com/developers/applications). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `DISCORD_SECRET=`.
|
||||||
|
> [!CAUTION]
|
||||||
|
> 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 Discord offers for utilizing their API on the [Documentation](https://discord.com/developers/docs/reference#api-reference).
|
||||||
|
|
||||||
|
#### Authentication Flow
|
||||||
|
|
||||||
|
After the completion of the Discord OAuth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
|
||||||
|
|
||||||
|
`discordAuth` method provides 4 set key data:
|
||||||
|
|
||||||
|
- `token`:
|
||||||
|
- Access token to make requests to the Discord 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. The duration of this token is not specified on the Discord docs.
|
||||||
|
- Type:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> [!NOTE]
|
||||||
|
> The refresh token Discord retrieves no implicit expiration
|
||||||
|
- `granted-scopes`:
|
||||||
|
- Scopes for which the user has granted permissions.
|
||||||
|
- Type: `string[]`.
|
||||||
|
- `user-discord`:
|
||||||
|
- User basic info retrieved from Discord
|
||||||
|
- Type:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar: string
|
||||||
|
discriminator: string
|
||||||
|
public_flags: number
|
||||||
|
premium_type: number
|
||||||
|
flags: number
|
||||||
|
banner: string | null
|
||||||
|
accent_color: string | null
|
||||||
|
global_name: string
|
||||||
|
avatar_decoration_data: string | null
|
||||||
|
banner_color: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request handler.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
app.get('/discord', (c) => {
|
||||||
|
const token = c.get('token')
|
||||||
|
const refreshToken = c.get('refresh-token')
|
||||||
|
const grantedScopes = c.get('granted-scopes')
|
||||||
|
const user = c.get('user-discord')
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
token,
|
||||||
|
refreshToken
|
||||||
|
grantedScopes,
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Refresh Token
|
||||||
|
|
||||||
|
Once the user token expires you can refresh their token wihtout the need to prompt the user again for access. In such scenario, you can utilize the `refreshToken` method, which accepts the `client_id`, `client_secret` and `refresh_token` as parameters.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The `refresh_token` can be used once. Once the token is refreshed Discord gives you a new `refresh_token` along with the new token.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { discordAuth, refreshToken } from '@hono/oauth-providers/discord'
|
||||||
|
|
||||||
|
app.post('/discord/refresh', async (c, next) => {
|
||||||
|
const newTokens = await refreshToken(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN)
|
||||||
|
|
||||||
|
// newTokenes = {
|
||||||
|
// token_type: 'bear',
|
||||||
|
// access_token: 'skbjbfhj3b4348wdvbwje239'
|
||||||
|
// expires_in: 60000
|
||||||
|
// refresh_token: 'sfcb0dwd0hdeh29db'
|
||||||
|
// scope: "identify email"
|
||||||
|
// }
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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, the `client_id`, `client_secret` and the `token` to be revoked as parameters.
|
||||||
|
|
||||||
|
It returns a `boolean` to tell whether the token was revoked or not.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { discordAuth, revokeToken } from '@hono/oauth-providers/discord'
|
||||||
|
|
||||||
|
app.post('/remove-user', async (c, next) => {
|
||||||
|
const revoked = await revokeToken(CLIENT_ID, CLIENT_SECRET, USER_TOKEN)
|
||||||
|
|
||||||
|
// revoked = true | false
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
monoald https://github.com/monoald
|
monoald https://github.com/monoald
|
||||||
|
|
|
@ -73,6 +73,16 @@
|
||||||
"types": "./dist/providers/x/index.d.ts",
|
"types": "./dist/providers/x/index.d.ts",
|
||||||
"default": "./dist/providers/x/index.js"
|
"default": "./dist/providers/x/index.js"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"./discord": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/providers/discord/index.d.mts",
|
||||||
|
"default": "./dist/providers/discord/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/providers/discord/index.d.ts",
|
||||||
|
"default": "./dist/providers/discord/index.js"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
|
@ -91,6 +101,9 @@
|
||||||
],
|
],
|
||||||
"x": [
|
"x": [
|
||||||
"./dist/providers/x/index.d.ts"
|
"./dist/providers/x/index.d.ts"
|
||||||
|
],
|
||||||
|
"discord": [
|
||||||
|
"./dist/providers/discord/index.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
|
||||||
|
import type { Token } from '../../types'
|
||||||
|
import { toQueryParams } from '../../utils/objectToQuery'
|
||||||
|
import type {
|
||||||
|
DiscordErrorResponse,
|
||||||
|
DiscordMeResponse,
|
||||||
|
DiscordTokenResponse,
|
||||||
|
DiscordUser,
|
||||||
|
Scopes,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type FacebookAuthFlow = {
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
redirect_uri: string
|
||||||
|
scope: Scopes[]
|
||||||
|
state: string
|
||||||
|
code: string | undefined
|
||||||
|
token: Token | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthFlow {
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
redirect_uri: string
|
||||||
|
scope: string
|
||||||
|
state: string
|
||||||
|
code: string | undefined
|
||||||
|
token: Token | undefined
|
||||||
|
refresh_token: Token | undefined
|
||||||
|
granted_scopes: string[] | undefined
|
||||||
|
user: Partial<DiscordUser> | undefined
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
code,
|
||||||
|
token,
|
||||||
|
}: FacebookAuthFlow) {
|
||||||
|
this.client_id = client_id
|
||||||
|
this.client_secret = client_secret
|
||||||
|
this.redirect_uri = redirect_uri
|
||||||
|
this.scope = scope.join(' ')
|
||||||
|
this.state = state
|
||||||
|
this.code = code
|
||||||
|
this.refresh_token = undefined
|
||||||
|
this.token = token
|
||||||
|
this.granted_scopes = undefined
|
||||||
|
this.user = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect() {
|
||||||
|
const parsedOptions = toQueryParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: this.client_id,
|
||||||
|
scope: this.scope,
|
||||||
|
state: this.state,
|
||||||
|
prompt: 'consent',
|
||||||
|
redirect_uri: this.redirect_uri,
|
||||||
|
})
|
||||||
|
return `https://discord.com/oauth2/authorize?${parsedOptions}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTokenFromCode() {
|
||||||
|
const parsedOptions = toQueryParams({
|
||||||
|
client_id: this.client_id,
|
||||||
|
client_secret: this.client_secret,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: this.code,
|
||||||
|
redirect_uri: this.redirect_uri,
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = 'https://discord.com/api/oauth2/token'
|
||||||
|
|
||||||
|
const response = (await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: parsedOptions,
|
||||||
|
}).then((res) => res.json())) as DiscordTokenResponse | DiscordErrorResponse
|
||||||
|
|
||||||
|
if ('error_description' in response)
|
||||||
|
throw new HTTPException(400, { message: response.error_description })
|
||||||
|
if ('error' in response) throw new HTTPException(400, { message: response.error })
|
||||||
|
if ('message' in response) throw new HTTPException(400, { message: response.message })
|
||||||
|
|
||||||
|
if ('access_token' in response) {
|
||||||
|
this.token = {
|
||||||
|
token: response.access_token,
|
||||||
|
expires_in: response.expires_in,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('refresh_token' in response) {
|
||||||
|
this.refresh_token = {
|
||||||
|
token: response.refresh_token,
|
||||||
|
expires_in: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('scope' in response) this.granted_scopes = response.scope.split(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserData() {
|
||||||
|
await this.getTokenFromCode()
|
||||||
|
const response = (await fetch('https://discord.com/api/oauth2/@me', {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.token?.token}`,
|
||||||
|
},
|
||||||
|
}).then((res) => res.json())) as DiscordMeResponse | DiscordErrorResponse
|
||||||
|
|
||||||
|
if ('error_description' in response)
|
||||||
|
throw new HTTPException(400, { message: response.error_description })
|
||||||
|
if ('error' in response) throw new HTTPException(400, { message: response.error })
|
||||||
|
if ('message' in response) throw new HTTPException(400, { message: response.message })
|
||||||
|
|
||||||
|
if ('user' in response) this.user = response.user
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
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 { Scopes } from './types'
|
||||||
|
|
||||||
|
export function discordAuth(options: {
|
||||||
|
scope: Scopes[]
|
||||||
|
client_id?: string
|
||||||
|
client_secret?: string
|
||||||
|
}): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
// Generate encoded "keys"
|
||||||
|
const newState = getRandomState()
|
||||||
|
|
||||||
|
// Create new Auth instance
|
||||||
|
const auth = new AuthFlow({
|
||||||
|
client_id: options.client_id || (c.env?.DISCORD_ID as string),
|
||||||
|
client_secret: options.client_secret || (c.env?.DISCORD_SECRET as string),
|
||||||
|
redirect_uri: c.req.url.split('?')[0],
|
||||||
|
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')),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 discord
|
||||||
|
await auth.getUserData()
|
||||||
|
|
||||||
|
// Set return info
|
||||||
|
c.set('token', auth.token)
|
||||||
|
c.set('refresh-token', auth.refresh_token)
|
||||||
|
c.set('user-discord', auth.user)
|
||||||
|
c.set('granted-scopes', auth.granted_scopes)
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
export { discordAuth } from './discordAuth'
|
||||||
|
export { refreshToken } from './refreshToken'
|
||||||
|
export { revokeToken } from './revokeToken'
|
||||||
|
export * from './types'
|
||||||
|
import type { OAuthVariables } from '../../types'
|
||||||
|
import type { DiscordUser } from './types'
|
||||||
|
|
||||||
|
declare module 'hono' {
|
||||||
|
interface ContextVariableMap extends OAuthVariables {
|
||||||
|
'user-discord': Partial<DiscordUser> | undefined
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
import { toQueryParams } from '../../utils/objectToQuery'
|
||||||
|
import type { DiscordTokenResponse } from './types'
|
||||||
|
|
||||||
|
export async function refreshToken(
|
||||||
|
client_id: string,
|
||||||
|
client_secret: string,
|
||||||
|
refresh_token: string
|
||||||
|
): Promise<DiscordTokenResponse> {
|
||||||
|
const params = toQueryParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = (await fetch('https://discord.com/api/oauth2/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params,
|
||||||
|
}).then((res) => res.json())) as DiscordTokenResponse | { error: string }
|
||||||
|
|
||||||
|
if ('error' in response) throw new HTTPException(400, { message: response.error })
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
import { toQueryParams } from '../../utils/objectToQuery'
|
||||||
|
|
||||||
|
export async function revokeToken(
|
||||||
|
client_id: string,
|
||||||
|
client_secret: string,
|
||||||
|
token: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const params = toQueryParams({
|
||||||
|
token_type_hint: 'access_token',
|
||||||
|
token,
|
||||||
|
client_id: client_id,
|
||||||
|
client_secret,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch('https://discord.com/api/oauth2/token/revoke', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) throw new HTTPException(400, { message: 'Something went wrong' })
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
export type Scopes =
|
||||||
|
| 'activities.read'
|
||||||
|
| 'activities.write'
|
||||||
|
| 'applications.builds.read'
|
||||||
|
| 'applications.builds.upload'
|
||||||
|
| 'applications.commands'
|
||||||
|
| 'applications.commands.update'
|
||||||
|
| 'applications.commands.permissions.update'
|
||||||
|
| 'applications.entitlements'
|
||||||
|
| 'applications.store.update'
|
||||||
|
| 'bot'
|
||||||
|
| 'connections'
|
||||||
|
| 'dm_channels.read'
|
||||||
|
| 'email'
|
||||||
|
| 'gdm.join'
|
||||||
|
| 'guilds'
|
||||||
|
| 'guilds.join'
|
||||||
|
| 'guilds.members.read'
|
||||||
|
| 'identify'
|
||||||
|
| 'messages.read'
|
||||||
|
| 'relationships.read'
|
||||||
|
| 'role_connections.write'
|
||||||
|
| 'rpc'
|
||||||
|
| 'rpc.activities.write'
|
||||||
|
| 'rpc.notifications.read'
|
||||||
|
| 'rpc.voice.read'
|
||||||
|
| 'rpc.voice.write'
|
||||||
|
| 'voice'
|
||||||
|
| 'webhook.incoming'
|
||||||
|
|
||||||
|
export type DiscordErrorResponse = {
|
||||||
|
message?: string
|
||||||
|
code?: number
|
||||||
|
error?: string
|
||||||
|
error_description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscordTokenResponse = {
|
||||||
|
token_type: string
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
refresh_token: string
|
||||||
|
scope: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordMeResponse {
|
||||||
|
application: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string | null
|
||||||
|
description: string
|
||||||
|
type: string
|
||||||
|
bot: {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar: string | null
|
||||||
|
discriminator: string
|
||||||
|
public_flags: number
|
||||||
|
premium_type: number
|
||||||
|
flags: number
|
||||||
|
bot: boolean
|
||||||
|
banner: string | null
|
||||||
|
accent_color: string | null
|
||||||
|
global_name: string | null
|
||||||
|
avatar_decoration_data: string | null
|
||||||
|
banner_color: string | null
|
||||||
|
}
|
||||||
|
summary: string
|
||||||
|
bot_public: boolean
|
||||||
|
bot_require_code_grant: boolean
|
||||||
|
verify_key: string
|
||||||
|
flags: number
|
||||||
|
hook: boolean
|
||||||
|
is_monetized: boolean
|
||||||
|
}
|
||||||
|
expires: string
|
||||||
|
scopes: string[]
|
||||||
|
user: DiscordUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordUser {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar: string
|
||||||
|
discriminator: string
|
||||||
|
public_flags: number
|
||||||
|
premium_type: number
|
||||||
|
flags: number
|
||||||
|
banner: string | null
|
||||||
|
accent_color: string | null
|
||||||
|
global_name: string
|
||||||
|
avatar_decoration_data: string | null
|
||||||
|
banner_color: string | null
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
} from '../src/providers/google/types'
|
} from '../src/providers/google/types'
|
||||||
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
|
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
|
||||||
import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x'
|
import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x'
|
||||||
|
import { DiscordErrorResponse, DiscordTokenResponse } from '../src/providers/discord'
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
// Google
|
// Google
|
||||||
|
@ -125,6 +126,29 @@ export const handlers = [
|
||||||
return HttpResponse.json({ revoked: true })
|
return HttpResponse.json({ revoked: true })
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
// Discord
|
||||||
|
http.post(
|
||||||
|
'https://discord.com/api/oauth2/token',
|
||||||
|
async ({
|
||||||
|
request,
|
||||||
|
}): Promise<StrictResponse<Partial<DiscordTokenResponse> | DiscordErrorResponse>> => {
|
||||||
|
const params = new URLSearchParams(await request.text())
|
||||||
|
const code = params.get('code')
|
||||||
|
const grant_type = params.get('grant_type')
|
||||||
|
if (grant_type === 'refresh_token') {
|
||||||
|
const refresh_token = params.get('refresh_token')
|
||||||
|
if (refresh_token === 'wrong-refresh-token') {
|
||||||
|
return HttpResponse.json(discordRefreshTokenError)
|
||||||
|
}
|
||||||
|
return HttpResponse.json(discordRefreshToken)
|
||||||
|
}
|
||||||
|
if (code === dummyCode) {
|
||||||
|
return HttpResponse.json(discordToken)
|
||||||
|
}
|
||||||
|
return HttpResponse.json(discordCodeError)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
http.get('https://discord.com/api/oauth2/@me', () => HttpResponse.json(discordUser)),
|
||||||
]
|
]
|
||||||
|
|
||||||
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
|
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
|
||||||
|
@ -359,3 +383,42 @@ export const xUser = {
|
||||||
name: 'Carlos Aldazosa',
|
name: 'Carlos Aldazosa',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const discordToken = {
|
||||||
|
token_type: 'bearer',
|
||||||
|
expires_in: 7200,
|
||||||
|
access_token:
|
||||||
|
'RkNwZzE4X0EtRmNkWTktN1hoYmdWSFQ4RjBPTzhvNGZod01lZmIxSjY0Xy1pOjE3MDEyOTYyMTY1NjM6MToxOmF0OjE',
|
||||||
|
scope: 'identify email',
|
||||||
|
refresh_token:
|
||||||
|
'R0d4OW1raGIwOVZGekZJWjZBbUhqOUZkb0k2UzJ1MkNEVnA4M1J0VmFTOWI3OjE3MDEyOTYyMTY1NjM6MToxOnJ0OjE',
|
||||||
|
}
|
||||||
|
export const discordCodeError = {
|
||||||
|
error: 'The Code you send is invalid.',
|
||||||
|
}
|
||||||
|
export const discordUser = {
|
||||||
|
user: {
|
||||||
|
id: '5869901058880055',
|
||||||
|
username: 'monoald',
|
||||||
|
avatar: 'e578fa5518c158ff',
|
||||||
|
discriminator: '0',
|
||||||
|
public_flags: 0,
|
||||||
|
premium_type: 0,
|
||||||
|
flags: 0,
|
||||||
|
banner: null,
|
||||||
|
accent_color: null,
|
||||||
|
global_name: 'monoald',
|
||||||
|
avatar_decoration_data: null,
|
||||||
|
banner_color: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export const discordRefreshToken = {
|
||||||
|
token_type: 'bearer',
|
||||||
|
expires_in: 7200,
|
||||||
|
access_token: 'isdFho34isdX6hd3vODOFFNubUEBosihjcXifjdC34dsdsd349Djs9cgSA2',
|
||||||
|
scope: 'tweet.read users.read follows.read follows.write offline.access',
|
||||||
|
refresh_token: 'VZGekZJWjZBbUhqOUZkb0k2UzJ1MkNEVnTYyMTY1NjM6MToxOnJ0Ojsdsd562x',
|
||||||
|
}
|
||||||
|
export const discordRefreshTokenError = {
|
||||||
|
error: 'Invalid Refresh Token.',
|
||||||
|
}
|
||||||
|
|
|
@ -31,8 +31,20 @@ import {
|
||||||
xRefreshToken,
|
xRefreshToken,
|
||||||
xRefreshTokenError,
|
xRefreshTokenError,
|
||||||
xRevokeTokenError,
|
xRevokeTokenError,
|
||||||
|
discordCodeError,
|
||||||
|
discordUser,
|
||||||
|
discordToken,
|
||||||
|
discordRefreshToken,
|
||||||
|
discordRefreshTokenError,
|
||||||
} from './handlers'
|
} from './handlers'
|
||||||
|
|
||||||
|
import {
|
||||||
|
refreshToken as discordRefresh,
|
||||||
|
revokeToken as discordRevoke,
|
||||||
|
discordAuth,
|
||||||
|
DiscordUser,
|
||||||
|
} from '../src/providers/discord'
|
||||||
|
|
||||||
const server = setupServer(...handlers)
|
const server = setupServer(...handlers)
|
||||||
server.listen()
|
server.listen()
|
||||||
|
|
||||||
|
@ -42,6 +54,7 @@ const client_secret = 'SDJS943hS_jj45dummysecret'
|
||||||
describe('OAuth Middleware', () => {
|
describe('OAuth Middleware', () => {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
|
// Google
|
||||||
app.use(
|
app.use(
|
||||||
'/google',
|
'/google',
|
||||||
googleAuth({
|
googleAuth({
|
||||||
|
@ -62,6 +75,7 @@ describe('OAuth Middleware', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Facebook
|
||||||
app.use(
|
app.use(
|
||||||
'/facebook',
|
'/facebook',
|
||||||
facebookAuth({
|
facebookAuth({
|
||||||
|
@ -92,6 +106,7 @@ describe('OAuth Middleware', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Github
|
||||||
app.use(
|
app.use(
|
||||||
'/github/app',
|
'/github/app',
|
||||||
githubAuth({
|
githubAuth({
|
||||||
|
@ -133,6 +148,7 @@ describe('OAuth Middleware', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// LinkedIn
|
||||||
app.use(
|
app.use(
|
||||||
'/linkedin',
|
'/linkedin',
|
||||||
linkedinAuth({
|
linkedinAuth({
|
||||||
|
@ -155,6 +171,7 @@ describe('OAuth Middleware', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// X
|
||||||
app.use(
|
app.use(
|
||||||
'/x',
|
'/x',
|
||||||
xAuth({
|
xAuth({
|
||||||
|
@ -216,6 +233,53 @@ describe('OAuth Middleware', () => {
|
||||||
return c.json(response)
|
return c.json(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Discord
|
||||||
|
app.use(
|
||||||
|
'/discord',
|
||||||
|
discordAuth({
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
scope: ['identify', 'email'],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
app.get('/discord', (c) => {
|
||||||
|
const token = c.get('token')
|
||||||
|
const refreshToken = c.get('refresh-token')
|
||||||
|
const user = c.get('user-discord')
|
||||||
|
const grantedScopes = c.get('granted-scopes')
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
grantedScopes,
|
||||||
|
user,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
app.get('/discord/refresh', async (c) => {
|
||||||
|
const response = await discordRefresh(
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
'MzJvY0QyNmNzWUtBU3BUelpOU1NLdXFOd05qdGROZFhtR3o3QkpPNHZpQ2xrOjE3MDEyOTU0ODkxMzM6MTowOnJ0OjE'
|
||||||
|
)
|
||||||
|
return c.json(response)
|
||||||
|
})
|
||||||
|
app.get('/discord/refresh/error', async (c) => {
|
||||||
|
const response = await discordRefresh(client_id, client_secret, 'wrong-refresh-token')
|
||||||
|
return c.json(response)
|
||||||
|
})
|
||||||
|
app.get('/discord/revoke', async (c) => {
|
||||||
|
const response = await discordRevoke(
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
'RkNwZzE4X0EtRmNkWTktN1hoYmdWSFQ4RjBPTzhvNGZod01lZmIxSjY0Xy1pOjE3MDEyOTYyMTY1NjM6MToxOmF0OjE'
|
||||||
|
)
|
||||||
|
return c.json(response)
|
||||||
|
})
|
||||||
|
app.get('/discord/revoke/error', async (c) => {
|
||||||
|
const response = await discordRevoke(client_id, client_secret, 'wrong-token')
|
||||||
|
return c.json(response)
|
||||||
|
})
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
server.listen()
|
server.listen()
|
||||||
})
|
})
|
||||||
|
@ -515,4 +579,69 @@ describe('OAuth Middleware', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('discordAuth middleware', () => {
|
||||||
|
describe('middleware', () => {
|
||||||
|
it('Should redirect', async () => {
|
||||||
|
const res = await app.request('/discord')
|
||||||
|
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(302)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Prevent CSRF attack', async () => {
|
||||||
|
const res = await app.request(`/discord?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('/discord?code=9348ffdsd-sdsdbad-code')
|
||||||
|
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(await res.text()).toBe(discordCodeError.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should work with received code', async () => {
|
||||||
|
const res = await app.request(`/discord?code=${dummyCode}`)
|
||||||
|
const response = (await res.json()) as {
|
||||||
|
token: Token
|
||||||
|
refreshToken: Token
|
||||||
|
user: DiscordUser
|
||||||
|
grantedScopes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(response.user).toEqual(discordUser.user)
|
||||||
|
expect(response.grantedScopes).toEqual(['identify', 'email'])
|
||||||
|
expect(response.token).toEqual({
|
||||||
|
token: discordToken.access_token,
|
||||||
|
expires_in: discordToken.expires_in,
|
||||||
|
})
|
||||||
|
expect(response.refreshToken).toEqual({
|
||||||
|
token: discordToken.refresh_token,
|
||||||
|
expires_in: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Refresh Token', () => {
|
||||||
|
it('Should refresh token', async () => {
|
||||||
|
const res = await app.request('/discord/refresh')
|
||||||
|
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(await res.json()).toEqual(discordRefreshToken)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should return error for refresh', async () => {
|
||||||
|
const res = await app.request('/discord/refresh/error')
|
||||||
|
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(await res.text()).toBe(discordRefreshTokenError.error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue