feat(oauth-providers): Add X (Twitter) to OAuth providers (#283)

* [FEAT] x (Twitter) provider

* [UPDATE] replace crypto-js with native api

* [UPDATE] Remove unused dependencies
pull/295/head
Carlos Sanjines Aldazosa 2023-12-08 20:18:12 -04:00 committed by GitHub
parent 80f46a4c40
commit aa9527b9e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 828 additions and 6 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/oauth-providers': minor
---
Add X (Twitter) provider

View File

@ -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). <br />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). <br />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.<br /> Review all the scopes X(Twitter) offers for utilizing their API on the [Documentation](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code). <br />If not sent the default fields x set are `id`, `name` and `username.`
- `fields`:
- 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

View File

@ -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"
]
}
},

View File

@ -51,7 +51,7 @@ export class AuthFlow {
...(this.oauthApp && { scope: this.scope }),
})
return url.concat(queryParams);
return url.concat(queryParams)
}
private async getTokenFromCode() {

View File

@ -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<XUser> | 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
}
}

View File

@ -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<XUser> | undefined
}
}

View File

@ -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<XTokenResponse> {
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
}

View File

@ -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<boolean> {
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
}

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -0,0 +1,32 @@
type Challenge = {
codeVerifier: string
codeChallenge: string
}
export async function getCodeChallenge(): Promise<Challenge> {
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(/=+$/, '')
}

View File

@ -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<StrictResponse<Partial<XTokenResponse> | 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<StrictResponse<XRevokeResponse | XErrorResponse>> => {
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',
},
}

View File

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