diff --git a/.changeset/nice-crews-glow.md b/.changeset/nice-crews-glow.md
new file mode 100644
index 00000000..e2dd9a87
--- /dev/null
+++ b/.changeset/nice-crews-glow.md
@@ -0,0 +1,5 @@
+---
+'@hono/oauth-providers': minor
+---
+
+Add Discord provider
diff --git a/packages/oauth-providers/README.md b/packages/oauth-providers/README.md
index 24f66266..7b381d93 100644
--- a/packages/oauth-providers/README.md
+++ b/packages/oauth-providers/README.md
@@ -105,6 +105,7 @@ export default app
- 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).
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.
- `scope`:
- Type: `string[]`.
@@ -227,6 +228,7 @@ export default app
- Type: `string`.
- `Required`.
- Your app client secret. You can find this value in the App Dashboard [Dashboard page](https://developers.facebook.com/apps).
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.
- `scope`:
- Type: `string[]`.
@@ -309,6 +311,7 @@ GitHub provides two types of Apps to utilize its API: the `GitHub App` and the `
- `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.
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.
- `scope`:
- Type: `string[]`.
@@ -477,6 +480,7 @@ LinkedIn provides two types of Authorization to utilize its API: the `Member Aut
- `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).
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.
- `scope`:
- Type: `string[]`.
@@ -630,10 +634,7 @@ app.use(
client_id: Bun.env.X_ID,
client_secret: Bun.env.X_SECRET,
scope: ['tweet.read', 'users.read', 'offline.access'],
- fields: [
- 'profile_image_url',
- 'url',
- ]
+ fields: ['profile_image_url', 'url'],
})
)
@@ -649,16 +650,17 @@ export default app
- `client_secret`:
- Type: `string`.
- `Required`.
- - Your app client secret. You can find this value in the [Developer Portal](https://console.developers.google.com/apis/credentials).
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).
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.
- `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.
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).
If not sent the default fields x set are `id`, `name` and `username.`
- `fields`:
- - Type: `string[]`.
- - `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).
+ - Type: `string[]`.
+ - `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).
#### 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.
- Type: `string[]`.
- `user-x`:
- - User basic info retrieved from Google
+ - User basic info retrieved from X
- Type:
```
{
created_at: string
- description: string
- entities: {
- url: {
- urls: {
- start: number
- end: number
- url: string
- expanded_url: string
- display_url: string
- }
- }
- }
- id: string
- location: string
- most_recent_tweet_id: string
- name: string
- profile_image_url: string
- protected: boolean
- public_metrics: {
- followers_count: number
- following_count: number
- tweet_count: number
- listed_count: number
- like_count: number
- }
- url: string
- username: string
- verified_type: string
- verified: boolean
+ description: string
+ entities: {
+ url: {
+ urls: {
+ start: number
+ end: number
+ url: string
+ expanded_url: string
+ display_url: string
+ }
+ }
+ }
+ id: string
+ location: string
+ most_recent_tweet_id: string
+ name: string
+ profile_image_url: string
+ protected: boolean
+ public_metrics: {
+ followers_count: number
+ following_count: number
+ tweet_count: number
+ listed_count: number
+ like_count: number
+ }
+ url: string
+ username: string
+ verified_type: string
+ verified: boolean
}
```
> 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
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).
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).
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.
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
monoald https://github.com/monoald
diff --git a/packages/oauth-providers/package.json b/packages/oauth-providers/package.json
index 7febb71b..b9c6b455 100644
--- a/packages/oauth-providers/package.json
+++ b/packages/oauth-providers/package.json
@@ -73,6 +73,16 @@
"types": "./dist/providers/x/index.d.ts",
"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": {
@@ -91,6 +101,9 @@
],
"x": [
"./dist/providers/x/index.d.ts"
+ ],
+ "discord": [
+ "./dist/providers/discord/index.d.ts"
]
}
},
diff --git a/packages/oauth-providers/src/providers/discord/authFlow.ts b/packages/oauth-providers/src/providers/discord/authFlow.ts
new file mode 100644
index 00000000..20eefb2f
--- /dev/null
+++ b/packages/oauth-providers/src/providers/discord/authFlow.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 | 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
+ }
+}
diff --git a/packages/oauth-providers/src/providers/discord/discordAuth.ts b/packages/oauth-providers/src/providers/discord/discordAuth.ts
new file mode 100644
index 00000000..86439474
--- /dev/null
+++ b/packages/oauth-providers/src/providers/discord/discordAuth.ts
@@ -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()
+ }
+}
diff --git a/packages/oauth-providers/src/providers/discord/index.ts b/packages/oauth-providers/src/providers/discord/index.ts
new file mode 100644
index 00000000..bb3435ab
--- /dev/null
+++ b/packages/oauth-providers/src/providers/discord/index.ts
@@ -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 | undefined
+ }
+}
diff --git a/packages/oauth-providers/src/providers/discord/refreshToken.ts b/packages/oauth-providers/src/providers/discord/refreshToken.ts
new file mode 100644
index 00000000..797ca260
--- /dev/null
+++ b/packages/oauth-providers/src/providers/discord/refreshToken.ts
@@ -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 {
+ 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
+}
diff --git a/packages/oauth-providers/src/providers/discord/revokeToken.ts b/packages/oauth-providers/src/providers/discord/revokeToken.ts
new file mode 100644
index 00000000..b390c3fb
--- /dev/null
+++ b/packages/oauth-providers/src/providers/discord/revokeToken.ts
@@ -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 {
+ 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
+}
diff --git a/packages/oauth-providers/src/providers/discord/types.ts b/packages/oauth-providers/src/providers/discord/types.ts
new file mode 100644
index 00000000..a8d5248b
--- /dev/null
+++ b/packages/oauth-providers/src/providers/discord/types.ts
@@ -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
+}
diff --git a/packages/oauth-providers/test/handlers.ts b/packages/oauth-providers/test/handlers.ts
index 41bfdb3b..e500029e 100644
--- a/packages/oauth-providers/test/handlers.ts
+++ b/packages/oauth-providers/test/handlers.ts
@@ -14,6 +14,7 @@ import type {
} from '../src/providers/google/types'
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x'
+import { DiscordErrorResponse, DiscordTokenResponse } from '../src/providers/discord'
export const handlers = [
// Google
@@ -125,6 +126,29 @@ export const handlers = [
return HttpResponse.json({ revoked: true })
}
),
+ // Discord
+ http.post(
+ 'https://discord.com/api/oauth2/token',
+ async ({
+ request,
+ }): Promise | 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'
@@ -359,3 +383,42 @@ export const xUser = {
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.',
+}
diff --git a/packages/oauth-providers/test/index.test.ts b/packages/oauth-providers/test/index.test.ts
index 89b4a28a..ac10c91c 100644
--- a/packages/oauth-providers/test/index.test.ts
+++ b/packages/oauth-providers/test/index.test.ts
@@ -31,8 +31,20 @@ import {
xRefreshToken,
xRefreshTokenError,
xRevokeTokenError,
+ discordCodeError,
+ discordUser,
+ discordToken,
+ discordRefreshToken,
+ discordRefreshTokenError,
} from './handlers'
+import {
+ refreshToken as discordRefresh,
+ revokeToken as discordRevoke,
+ discordAuth,
+ DiscordUser,
+} from '../src/providers/discord'
+
const server = setupServer(...handlers)
server.listen()
@@ -42,6 +54,7 @@ const client_secret = 'SDJS943hS_jj45dummysecret'
describe('OAuth Middleware', () => {
const app = new Hono()
+ // Google
app.use(
'/google',
googleAuth({
@@ -62,6 +75,7 @@ describe('OAuth Middleware', () => {
})
})
+ // Facebook
app.use(
'/facebook',
facebookAuth({
@@ -92,6 +106,7 @@ describe('OAuth Middleware', () => {
})
})
+ // Github
app.use(
'/github/app',
githubAuth({
@@ -133,6 +148,7 @@ describe('OAuth Middleware', () => {
})
})
+ // LinkedIn
app.use(
'/linkedin',
linkedinAuth({
@@ -155,6 +171,7 @@ describe('OAuth Middleware', () => {
})
})
+ // X
app.use(
'/x',
xAuth({
@@ -216,6 +233,53 @@ describe('OAuth Middleware', () => {
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(() => {
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)
+ })
+ })
+ })
})