diff --git a/.changeset/seven-eagles-explain.md b/.changeset/seven-eagles-explain.md
new file mode 100644
index 00000000..c8ac2ab4
--- /dev/null
+++ b/.changeset/seven-eagles-explain.md
@@ -0,0 +1,5 @@
+---
+'@hono/oauth-providers': minor
+---
+
+Add X (Twitter) provider
diff --git a/packages/oauth-providers/README.md b/packages/oauth-providers/README.md
index 6bbce6ba..24f66266 100644
--- a/packages/oauth-providers/README.md
+++ b/packages/oauth-providers/README.md
@@ -1,6 +1,6 @@
# OAuth Providers Middleware
-Authentication middleware for [Hono](https://github.com/honojs/hono). This package offers a straightforward API for social login with platforms such as Facebook, GitHub, Google, and LinkedIn.
+Authentication middleware for [Hono](https://github.com/honojs/hono). This package offers a straightforward API for social login with platforms such as Facebook, GitHub, Google, LinkedIn and X(Twitter).
## Installation
@@ -178,7 +178,7 @@ app.get('/google', (c) => {
In certain use cases, you may need to programmatically revoke a user's access token. In such scenarios, you can utilize the `revokeToken` method, which accepts the `token` to be revoked as its unique parameter.
```ts
-import { googleAuth, revokeToken } from 'open-auth/google'
+import { googleAuth, revokeToken } from '@hono/oauth-providers/google'
app.post('/remove-user', async (c, next) => {
await revokeToken(USER_TOKEN)
@@ -607,7 +607,7 @@ In certain use cases, you may need to programmatically revoke a user's access to
- `string`.
```ts
-import { linkedinAuth, refreshToken } from 'open-auth/linkedin'
+import { linkedinAuth, refreshToken } from '@hono/oauth-providers/linkedin'
app.post('linkedin/refresh-token', async (c, next) => {
const token = await refreshToken(LINKEDIN_ID, LINKEDIN_SECRET, USER_REFRESH_TOKEN)
@@ -616,6 +616,166 @@ app.post('linkedin/refresh-token', async (c, next) => {
})
```
+### X (Twitter)
+
+```ts
+import { Hono } from 'hono'
+import { xAuth } from '@hono/oauth-providers/x'
+
+const app = new Hono()
+
+app.use(
+ '/x',
+ xAuth({
+ client_id: Bun.env.X_ID,
+ client_secret: Bun.env.X_SECRET,
+ scope: ['tweet.read', 'users.read', 'offline.access'],
+ fields: [
+ 'profile_image_url',
+ 'url',
+ ]
+ })
+)
+
+export default app
+```
+
+#### Parameters
+
+- `client_id`:
+ - Type: `string`.
+ - `Required`.
+ - Your app client ID. 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_ID=`.
+- `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=`.
+ > 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).
+
+#### Authentication Flow
+
+After the completion of the X OAuth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
+
+`xAuth` method provides 4 set key data:
+
+- `token`:
+ - Access token to make requests to the x 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 X docs.
+ - Type:
+ ```
+ {
+ token: string
+ expires_in: number
+ }
+ ```
+- `granted-scopes`:
+ - Scopes for which the user has granted permissions.
+ - Type: `string[]`.
+- `user-x`:
+ - User basic info retrieved from Google
+ - 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
+ }
+ ```
+
+> 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.
+
+```ts
+app.get('/x', (c) => {
+ const token = c.get('token')
+ const refreshToken = c.get('refresh-token')
+ const grantedScopes = c.get('granted-scopes')
+ const user = c.get('user-x')
+
+ 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.
+
+> The `refresh_token` can be used once. Once the token is refreshed X gives you a new `refresh_token` along with the new token.
+
+```ts
+import { xAuth, refreshToken } from '@hono/oauth-providers/x'
+
+app.post('/x/refresh', async (c, next) => {
+ await refreshToken(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN)
+
+ // ...
+})
+```
+
+#### 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 { xAuth, revokeToken } from '@hono/oauth-providers/x'
+
+app.post('/remove-user', async (c, next) => {
+ await revokeToken(CLIENT_ID, CLIENT_SECRET, USER_TOKEN)
+
+ // ...
+})
+```
+
## Author
monoald https://github.com/monoald
diff --git a/packages/oauth-providers/package.json b/packages/oauth-providers/package.json
index 2d7001d1..31522e5e 100644
--- a/packages/oauth-providers/package.json
+++ b/packages/oauth-providers/package.json
@@ -63,6 +63,16 @@
"types": "./dist/providers/linkedin/index.d.ts",
"default": "./dist/providers/linkedin/index.js"
}
+ },
+ "./x": {
+ "import": {
+ "types": "./dist/providers/x/index.d.mts",
+ "default": "./dist/providers/x/index.mjs"
+ },
+ "require": {
+ "types": "./dist/providers/x/index.d.ts",
+ "default": "./dist/providers/x/index.js"
+ }
}
},
"typesVersions": {
@@ -78,6 +88,9 @@
],
"linkedin": [
"./dist/providers/linkedin/index.d.ts"
+ ],
+ "x": [
+ "./dist/providers/x/index.d.ts"
]
}
},
diff --git a/packages/oauth-providers/src/providers/github/authFlow.ts b/packages/oauth-providers/src/providers/github/authFlow.ts
index c5fcb4d9..e48adcfe 100644
--- a/packages/oauth-providers/src/providers/github/authFlow.ts
+++ b/packages/oauth-providers/src/providers/github/authFlow.ts
@@ -51,7 +51,7 @@ export class AuthFlow {
...(this.oauthApp && { scope: this.scope }),
})
- return url.concat(queryParams);
+ return url.concat(queryParams)
}
private async getTokenFromCode() {
diff --git a/packages/oauth-providers/src/providers/x/authFlow.ts b/packages/oauth-providers/src/providers/x/authFlow.ts
new file mode 100644
index 00000000..42af584a
--- /dev/null
+++ b/packages/oauth-providers/src/providers/x/authFlow.ts
@@ -0,0 +1,130 @@
+import { HTTPException } from 'hono/http-exception'
+
+import type { Token } from '../../types'
+import { toQueryParams } from '../../utils/objectToQuery'
+import type { XErrorResponse, XFields, XMeResponse, XScopes, XTokenResponse, XUser } from './types'
+
+type XAuthFlow = {
+ client_id: string
+ client_secret: string
+ redirect_uri: string
+ scope: XScopes[]
+ fields: XFields[] | undefined
+ state: string
+ codeVerifier: string
+ codeChallenge: string
+ code: string | undefined
+}
+
+export class AuthFlow {
+ client_id: string
+ client_secret: string
+ redirect_uri: string
+ code: string | undefined
+ token: Token | undefined
+ refresh_token: Token | undefined
+ scope: string
+ fields: XFields[] | undefined
+ state: string | undefined
+ code_verifier: string
+ code_challenge: string
+ authToken: string
+ granted_scopes: string[] | undefined
+ user: Partial | undefined
+
+ constructor({
+ client_id,
+ client_secret,
+ redirect_uri,
+ scope,
+ fields,
+ state,
+ codeVerifier,
+ codeChallenge,
+ code,
+ }: XAuthFlow) {
+ if (client_id === undefined || client_secret === undefined || scope === undefined) {
+ throw new HTTPException(400, {
+ message: 'Required parameters were not found. Please provide them to proceed.',
+ })
+ }
+
+ this.client_id = client_id
+ this.client_secret = client_secret
+ this.redirect_uri = redirect_uri
+ this.scope = scope.join(' ')
+ this.fields = fields
+ this.state = state
+ this.code_verifier = codeVerifier
+ this.code_challenge = codeChallenge
+ this.authToken = btoa(`${encodeURIComponent(client_id)}:${encodeURIComponent(client_secret)}`)
+ this.code = code
+ this.token = undefined
+ this.refresh_token = undefined
+ this.granted_scopes = undefined
+ }
+
+ redirect() {
+ const parsedOptions = toQueryParams({
+ response_type: 'code',
+ redirect_uri: this.redirect_uri,
+ client_id: this.client_id,
+ scope: this.scope,
+ state: this.state,
+ code_challenge: this.code_challenge,
+ code_challenge_method: 'S256',
+ })
+ return `https://twitter.com/i/oauth2/authorize?${parsedOptions}`
+ }
+
+ private async getTokenFromCode() {
+ const parsedOptions = toQueryParams({
+ code: this.code,
+ grant_type: 'authorization_code',
+ client_id: this.client_id,
+ redirect_uri: this.redirect_uri,
+ code_verifier: this.code_verifier,
+ })
+ const response = (await fetch(`https://api.twitter.com/2/oauth2/token?${parsedOptions}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Basic ${this.authToken}`,
+ },
+ }).then((res) => res.json())) as XTokenResponse | XErrorResponse
+
+ if ('error' in response) throw new HTTPException(400, { message: response.error_description })
+
+ if ('access_token' in response) {
+ this.token = {
+ token: response.access_token,
+ expires_in: response.expires_in,
+ }
+
+ this.granted_scopes = response.scope.split(' ')
+
+ this.refresh_token = response.refresh_token
+ ? { token: response.refresh_token, expires_in: 0 }
+ : undefined
+ }
+ }
+
+ async getUserData() {
+ await this.getTokenFromCode()
+
+ const parsedOptions = toQueryParams({
+ 'user.fields': this.fields,
+ })
+
+ const response = (await fetch(`https://api.twitter.com/2/users/me?${parsedOptions}`, {
+ headers: {
+ authorization: `Bearer ${this.token?.token}`,
+ },
+ }).then((res) => res.json())) as XMeResponse | XErrorResponse
+
+ if ('error_description' in response)
+ throw new HTTPException(400, { message: response.error_description })
+
+ if ('data' in response) this.user = response.data
+ }
+}
diff --git a/packages/oauth-providers/src/providers/x/index.ts b/packages/oauth-providers/src/providers/x/index.ts
new file mode 100644
index 00000000..9dba1169
--- /dev/null
+++ b/packages/oauth-providers/src/providers/x/index.ts
@@ -0,0 +1,12 @@
+export { xAuth } from './xAuth'
+export { refreshToken } from './refreshToken'
+export { revokeToken } from './revokeToken'
+export * from './types'
+import type { OAuthVariables } from '../../types'
+import type { XUser } from './types'
+
+declare module 'hono' {
+ interface ContextVariableMap extends OAuthVariables {
+ 'user-x': Partial | undefined
+ }
+}
diff --git a/packages/oauth-providers/src/providers/x/refreshToken.ts b/packages/oauth-providers/src/providers/x/refreshToken.ts
new file mode 100644
index 00000000..c64795d7
--- /dev/null
+++ b/packages/oauth-providers/src/providers/x/refreshToken.ts
@@ -0,0 +1,28 @@
+import { HTTPException } from 'hono/http-exception'
+import { toQueryParams } from '../../utils/objectToQuery'
+import type { XErrorResponse, XTokenResponse } from './types'
+
+export async function refreshToken(
+ client_id: string,
+ client_secret: string,
+ refresh_token: string
+): Promise {
+ const authToken = btoa(`${encodeURIComponent(client_id)}:${encodeURIComponent(client_secret)}`)
+ const params = toQueryParams({
+ grant_type: 'refresh_token',
+ refresh_token: refresh_token,
+ })
+
+ const response = (await fetch(`https://api.twitter.com/2/oauth2/token?${params}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Basic ${authToken}`,
+ },
+ }).then((res) => res.json())) as XTokenResponse | XErrorResponse
+
+ if ('error_description' in response)
+ throw new HTTPException(400, { message: response.error_description })
+
+ return response
+}
diff --git a/packages/oauth-providers/src/providers/x/revokeToken.ts b/packages/oauth-providers/src/providers/x/revokeToken.ts
new file mode 100644
index 00000000..7cfcdf0d
--- /dev/null
+++ b/packages/oauth-providers/src/providers/x/revokeToken.ts
@@ -0,0 +1,28 @@
+import { HTTPException } from 'hono/http-exception'
+import { toQueryParams } from '../../utils/objectToQuery'
+import type { XErrorResponse, XRevokeResponse } from './types'
+
+export async function revokeToken(
+ client_id: string,
+ client_secret: string,
+ token: string
+): Promise {
+ const authToken = btoa(`${encodeURIComponent(client_id)}:${encodeURIComponent(client_secret)}`)
+ const params = toQueryParams({
+ token,
+ token_type_hint: 'access_token',
+ })
+
+ const response = (await fetch(`https://api.twitter.com/2/oauth2/revoke?${params}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Basic ${authToken}`,
+ },
+ }).then((res) => res.json())) as XRevokeResponse | XErrorResponse
+
+ if ('error_description' in response)
+ throw new HTTPException(400, { message: response.error_description })
+
+ return response.revoked
+}
diff --git a/packages/oauth-providers/src/providers/x/types.ts b/packages/oauth-providers/src/providers/x/types.ts
new file mode 100644
index 00000000..90906f78
--- /dev/null
+++ b/packages/oauth-providers/src/providers/x/types.ts
@@ -0,0 +1,91 @@
+export type XScopes =
+ | 'tweet.read'
+ | 'tweet.write'
+ | 'tweet.moderate.write'
+ | 'users.read'
+ | 'follows.read'
+ | 'follows.write'
+ | 'offline.access'
+ | 'space.read'
+ | 'mute.read'
+ | 'mute.write'
+ | 'like.read'
+ | 'like.write'
+ | 'list.read'
+ | 'list.write'
+ | 'block.read'
+ | 'block.write'
+ | 'bookmark.read'
+ | 'bookmark.write'
+
+export type XFields =
+ | 'created_at'
+ | 'description'
+ | 'entities'
+ | 'id'
+ | 'location'
+ | 'most_recent_tweet_id'
+ | 'name'
+ | 'pinned_tweet_id'
+ | 'profile_image_url'
+ | 'protected'
+ | 'public_metrics'
+ | 'url'
+ | 'username'
+ | 'verified'
+ | 'verified_type'
+ | 'withheld'
+
+export type XErrorResponse = {
+ error: string
+ error_description: string
+}
+
+export type XTokenResponse = {
+ access_token: string
+ expires_in: number
+ scope: string
+ token_type: string
+ refresh_token?: string
+}
+
+export type XMeResponse = {
+ data: XUser
+}
+
+export type XRevokeResponse = {
+ revoked: boolean
+}
+
+export type XUser = {
+ 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
+}
diff --git a/packages/oauth-providers/src/providers/x/xAuth.ts b/packages/oauth-providers/src/providers/x/xAuth.ts
new file mode 100644
index 00000000..34981284
--- /dev/null
+++ b/packages/oauth-providers/src/providers/x/xAuth.ts
@@ -0,0 +1,69 @@
+import type { MiddlewareHandler } from 'hono'
+import { getCookie, setCookie } from 'hono/cookie'
+import { HTTPException } from 'hono/http-exception'
+
+import { getCodeChallenge } from '../../utils/getCodeChallenge'
+import { getRandomState } from '../../utils/getRandomState'
+import { AuthFlow } from './authFlow'
+import type { XFields, XScopes } from './types'
+
+export function xAuth(options: {
+ scope: XScopes[]
+ fields?: XFields[]
+ client_id?: string
+ client_secret?: string
+}): MiddlewareHandler {
+ return async (c, next) => {
+ // Generate encoded "keys"
+ const newState = getRandomState()
+ const challenge = await getCodeChallenge()
+
+ const auth = new AuthFlow({
+ client_id: options.client_id || (c.env?.X_ID as string),
+ client_secret: options.client_secret || (c.env?.X_SECRET as string),
+ redirect_uri: c.req.url.split('?')[0],
+ scope: options.scope,
+ fields: options.fields,
+ state: newState,
+ codeVerifier: getCookie(c, 'code-verifier') || challenge.codeVerifier,
+ codeChallenge: challenge.codeChallenge,
+ code: c.req.query('code'),
+ })
+
+ // Avoid CSRF attack by checking state
+ if (c.req.url.includes('?')) {
+ const storedState = getCookie(c, 'state')
+ if (c.req.query('state') !== storedState) {
+ throw new HTTPException(401)
+ }
+ }
+
+ // Redirect to login dialog
+ if (!auth.code) {
+ setCookie(c, 'state', newState, {
+ maxAge: 60 * 10,
+ httpOnly: true,
+ path: '/',
+ // secure: true,
+ })
+ setCookie(c, 'code-verifier', challenge.codeVerifier, {
+ maxAge: 60 * 10,
+ httpOnly: true,
+ path: '/',
+ // secure: true,
+ })
+ return c.redirect(auth.redirect())
+ }
+
+ // Retrieve user data from x
+ await auth.getUserData()
+
+ // Set return info
+ c.set('token', auth.token)
+ c.set('refresh-token', auth.refresh_token)
+ c.set('user-x', auth.user)
+ c.set('granted-scopes', auth.granted_scopes)
+
+ await next()
+ }
+}
diff --git a/packages/oauth-providers/src/utils/getCodeChallenge.ts b/packages/oauth-providers/src/utils/getCodeChallenge.ts
new file mode 100644
index 00000000..606572a3
--- /dev/null
+++ b/packages/oauth-providers/src/utils/getCodeChallenge.ts
@@ -0,0 +1,32 @@
+type Challenge = {
+ codeVerifier: string
+ codeChallenge: string
+}
+
+export async function getCodeChallenge(): Promise {
+ const codeVerifier = generateRandomString()
+
+ const encoder = new TextEncoder()
+ const encoded = encoder.encode(codeVerifier)
+ const shaEncoded = await crypto.subtle.digest('SHA-256', encoded)
+ const strEncoded = btoa(String.fromCharCode(...new Uint8Array(shaEncoded)))
+ const codeChallenge = base64URLEncode(strEncoded)
+
+ return { codeVerifier, codeChallenge }
+}
+
+function generateRandomString() {
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
+ const length = Math.floor(Math.random() * (128 - 43 + 1)) + 43
+
+ const randomString = Array.from({ length }, () => {
+ const randomIndex = Math.floor(Math.random() * characters.length)
+ return characters.charAt(randomIndex)
+ }).join('')
+
+ return randomString
+}
+
+function base64URLEncode(str: string) {
+ return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
diff --git a/packages/oauth-providers/test/handlers.ts b/packages/oauth-providers/test/handlers.ts
index 4312b694..41bfdb3b 100644
--- a/packages/oauth-providers/test/handlers.ts
+++ b/packages/oauth-providers/test/handlers.ts
@@ -13,6 +13,7 @@ import type {
GoogleUser,
} from '../src/providers/google/types'
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
+import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x'
export const handlers = [
// Google
@@ -93,6 +94,37 @@ export const handlers = [
}
),
http.get('https://api.linkedin.com/v2/userinfo', () => HttpResponse.json(linkedInUser)),
+ // X
+ http.post(
+ 'https://api.twitter.com/2/oauth2/token',
+ async ({ request }): Promise | XErrorResponse>> => {
+ const code = new URLSearchParams(request.url.split('?')[1]).get('code')
+ const grant_type = new URLSearchParams(request.url.split('?')[1]).get('grant_type')
+ if (grant_type === 'refresh_token') {
+ const refresh_token = new URLSearchParams(request.url.split('?')[1]).get('refresh_token')
+ if (refresh_token === 'wrong-refresh-token') {
+ return HttpResponse.json(xRefreshTokenError)
+ }
+ return HttpResponse.json(xRefreshToken)
+ }
+ if (code === dummyCode) {
+ return HttpResponse.json(xToken)
+ }
+ return HttpResponse.json(xCodeError)
+ }
+ ),
+ http.get('https://api.twitter.com/2/users/me', () => HttpResponse.json(xUser)),
+ http.post(
+ 'https://api.twitter.com/2/oauth2/revoke',
+ async ({ request }): Promise> => {
+ const token = new URLSearchParams(request.url.split('?')[1]).get('token')
+ if (token === 'wrong-token') {
+ return HttpResponse.json(xRevokeTokenError)
+ }
+
+ return HttpResponse.json({ revoked: true })
+ }
+ ),
]
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
@@ -262,3 +294,68 @@ export const linkedInUser = {
email: 'example@email.com',
picture: 'https://www.severnedgevets.co.uk/sites/default/files/guides/kitten.png',
}
+
+export const xToken = {
+ token_type: 'bearer',
+ expires_in: 7200,
+ access_token:
+ 'RkNwZzE4X0EtRmNkWTktN1hoYmdWSFQ4RjBPTzhvNGZod01lZmIxSjY0Xy1pOjE3MDEyOTYyMTY1NjM6MToxOmF0OjE',
+ scope: 'tweet.read users.read follows.read follows.write offline.access',
+ refresh_token:
+ 'R0d4OW1raGIwOVZGekZJWjZBbUhqOUZkb0k2UzJ1MkNEVnA4M1J0VmFTOWI3OjE3MDEyOTYyMTY1NjM6MToxOnJ0OjE',
+}
+export const xRefreshToken = {
+ 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 xCodeError = {
+ error: 'The Code you send is invalid.',
+ error_description: 'The Code you send is invalid.',
+}
+export const xRefreshTokenError = {
+ error: 'Invalid.',
+ error_description: 'Invalid Refresh Token.',
+}
+export const xRevokeTokenError = {
+ error: 'Something went wrong.',
+ error_description: 'Unable to invalid token.',
+}
+export const xUser = {
+ data: {
+ entities: {
+ url: {
+ urls: [
+ {
+ start: 0,
+ end: 23,
+ url: 'https://t.co/J2mwejW4cB',
+ expanded_url: 'https://monoald.github.io/',
+ display_url: 'monoald.github.io',
+ },
+ ],
+ },
+ },
+ url: 'https://t.co/J2mwejW4cB',
+ description: '💻 Front-end Developer',
+ username: 'monoald',
+ protected: true,
+ verified: true,
+ public_metrics: {
+ followers_count: 1,
+ following_count: 2,
+ tweet_count: 3,
+ listed_count: 4,
+ like_count: 5,
+ },
+ location: 'La Paz - Bolivia',
+ most_recent_tweet_id: '123456987465',
+ verified_type: 'none',
+ id: '123456',
+ profile_image_url: 'https://www.severnedgevets.co.uk/sites/default/files/guides/kitten.png',
+ created_at: '1018-12-01T13:53:50.000Z',
+ name: 'Carlos Aldazosa',
+ },
+}
diff --git a/packages/oauth-providers/test/index.test.ts b/packages/oauth-providers/test/index.test.ts
index 0f77dc1c..89b4a28a 100644
--- a/packages/oauth-providers/test/index.test.ts
+++ b/packages/oauth-providers/test/index.test.ts
@@ -8,6 +8,8 @@ import { googleAuth } from '../src/providers/google'
import type { GoogleUser } from '../src/providers/google'
import { linkedinAuth } from '../src/providers/linkedin'
import type { LinkedInUser } from '../src/providers/linkedin'
+import type { XUser } from '../src/providers/x'
+import { refreshToken, revokeToken, xAuth } from '../src/providers/x'
import type { Token } from '../src/types'
import {
dummyToken,
@@ -23,6 +25,12 @@ import {
linkedInCodeError,
linkedInUser,
linkedInToken,
+ xCodeError,
+ xUser,
+ xToken,
+ xRefreshToken,
+ xRefreshTokenError,
+ xRevokeTokenError,
} from './handlers'
const server = setupServer(...handlers)
@@ -126,14 +134,14 @@ describe('OAuth Middleware', () => {
})
app.use(
- 'linkedin',
+ '/linkedin',
linkedinAuth({
client_id,
client_secret,
scope: ['email', 'openid', 'profile'],
})
)
- app.get('linkedin', (c) => {
+ app.get('/linkedin', (c) => {
const token = c.get('token')
const refreshToken = c.get('refresh-token')
const user = c.get('user-linkedin')
@@ -147,6 +155,67 @@ describe('OAuth Middleware', () => {
})
})
+ app.use(
+ '/x',
+ xAuth({
+ client_id,
+ client_secret,
+ scope: ['tweet.read', 'users.read', 'follows.read', 'follows.write', 'offline.access'],
+ fields: [
+ 'created_at',
+ 'description',
+ 'entities',
+ 'location',
+ 'most_recent_tweet_id',
+ 'pinned_tweet_id',
+ 'profile_image_url',
+ 'protected',
+ 'public_metrics',
+ 'url',
+ 'verified',
+ 'verified_type',
+ 'withheld',
+ ],
+ })
+ )
+ app.get('/x', (c) => {
+ const token = c.get('token')
+ const refreshToken = c.get('refresh-token')
+ const user = c.get('user-x')
+ const grantedScopes = c.get('granted-scopes')
+
+ return c.json({
+ token,
+ refreshToken,
+ grantedScopes,
+ user,
+ })
+ })
+ app.get('x/refresh', async (c) => {
+ const response = await refreshToken(
+ client_id,
+ client_secret,
+ 'MzJvY0QyNmNzWUtBU3BUelpOU1NLdXFOd05qdGROZFhtR3o3QkpPNHZpQ2xrOjE3MDEyOTU0ODkxMzM6MTowOnJ0OjE'
+ )
+ return c.json(response)
+ })
+ app.get('x/refresh/error', async (c) => {
+ const response = await refreshToken(client_id, client_secret, 'wrong-refresh-token')
+ return c.json(response)
+ })
+ app.get('/x/revoke', async (c) => {
+ const response = await revokeToken(
+ client_id,
+ client_secret,
+ 'RkNwZzE4X0EtRmNkWTktN1hoYmdWSFQ4RjBPTzhvNGZod01lZmIxSjY0Xy1pOjE3MDEyOTYyMTY1NjM6MToxOmF0OjE'
+ )
+ return c.json(response)
+ })
+ app.get('x/revoke/error', async (c) => {
+ const response = await revokeToken(client_id, client_secret, 'wrong-token')
+ return c.json(response)
+ })
+
beforeAll(() => {
server.listen()
})
@@ -358,4 +427,92 @@ describe('OAuth Middleware', () => {
})
})
})
+
+ describe('xAuth middleware', () => {
+ describe('middleware', () => {
+ it('Should redirect', async () => {
+ const res = await app.request('/x')
+
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(302)
+ })
+
+ it('Prevent CSRF attack', async () => {
+ const res = await app.request(`/x?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('/x?code=9348ffdsd-sdsdbad-code')
+
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(400)
+ expect(await res.text()).toBe(xCodeError.error_description)
+ })
+
+ it('Should work with received code', async () => {
+ const res = await app.request(`/x?code=${dummyCode}`)
+ const response = (await res.json()) as {
+ token: Token
+ refreshToken: Token
+ user: XUser
+ grantedScopes: string[]
+ }
+
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(200)
+ expect(response.user).toEqual(xUser.data)
+ expect(response.grantedScopes).toEqual([
+ 'tweet.read',
+ 'users.read',
+ 'follows.read',
+ 'follows.write',
+ 'offline.access',
+ ])
+ expect(response.token).toEqual({
+ token: xToken.access_token,
+ expires_in: xToken.expires_in,
+ })
+ expect(response.refreshToken).toEqual({
+ token: xToken.refresh_token,
+ expires_in: 0,
+ })
+ })
+ })
+
+ describe('Refresh Token', () => {
+ it('Should refresh token', async () => {
+ const res = await app.request('/x/refresh')
+
+ expect(res).not.toBeNull()
+ expect(await res.json()).toEqual(xRefreshToken)
+ })
+
+ it('Should return error for refresh', async () => {
+ const res = await app.request('/x/refresh/error')
+
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(400)
+ expect(await res.text()).toBe(xRefreshTokenError.error_description)
+ })
+ })
+
+ describe('Revoke Token', () => {
+ it('Should revoke token', async () => {
+ const res = await app.request('/x/revoke')
+
+ expect(res).not.toBeNull()
+ expect(await res.json()).toEqual(true)
+ })
+
+ it('Should return error for revoke', async () => {
+ const res = await app.request('/x/revoke/error')
+
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(400)
+ expect(await res.text()).toBe(xRevokeTokenError.error_description)
+ })
+ })
+ })
})