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