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) + }) + }) + }) })