feat(oauth-providers): Add Twitch OAuth Provider (#981)

* feat(twitch): Add type definitions for Twitch OAuth scopes

* feat(twitch): Add additional type definitions for Twitch moderator scopes

* feat(twitch): Add IRC and PubSub-specific chat scopes to types

* feat(twitch): Add new type definitions for Twitch API responses

* feat(twitch): Add new user-related scopes for Twitch API

* feat(twitch): Add type definitions and import paths for Twitch provider

* feat(twitch): Implement Twitch OAuth handlers and response types for mock api

* feat(twitch): Add revokeToken function to handle OAuth token revocation

* feat(twitch): Add Twitch OAuth middleware mocks and tests

* feat(twitch): Implement Twitch OAuth authentication flow and user data retrieval

* feat(twitch): Add custom state handling for Twitch OAuth middleware

* docs(twitch): Update README with Twitch OAuth integration details

* docs: Update Twitch API reference link for scopes in README

* fix(twitch): Remove error handling for error_description in auth flow

* refactor(twitch): Update token handling and response types for refresh and revoke

* feat(twitch): Add token validation function for Twitch OAuth

* feat(twitch): Add token validation handler and update response types

* docs: Add token validation section to README for Twitch integration

* chore(oauth-providers): changesets summary

* fix(twitch): make redirect_uri optional in twitchAuth options

* refactor(twitch): clean up commented code and improve test assertions

* refactor(twitch): improve type assertions for JSON responses

* refactor(twitch): update type assertion for JSON response handling

* semver amendment

Changed version from patch to minor

* docs: update README with token validation instructions for Twitch

---------

Co-authored-by: Younis-Ahmed <23105954+jonaahmed@users.noreply.github.com>
pull/1014/head
Younis 2025-03-12 11:55:47 +03:00 committed by GitHub
parent 4d67af162f
commit e5f383787c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 984 additions and 0 deletions

View File

@ -0,0 +1,17 @@
---
'@hono/oauth-providers': minor
---
These chages introduces a Twitch OAuth provider, expanding the middleware's OAuth offerings. It includes a new middleware for Twitch authentication, a dedicated `AuthFlow` class, token refreshing/revocation/validation, and comprehensive type definitions. Detailed tests ensure correct behavior and error handling.
- **Twitch OAuth Middleware `src/providers/twitch/twitchAuth.ts`:** Implements the core authentication flow, handling state management, redirects, and context variable setting (`token`, `refresh-token`, `user-twitch`, `granted-scopes`).
- **AuthFlow Class `src/providers/twitch/authFlow.ts`:** Encapsulates token exchange and user data retrieval, with robust error handling.
- **Token Operations `src/providers/twitch/refreshToken.ts`:** Provides functions for refreshing and revoking tokens.
- **Type Definitions `src/providers/twitch/types.ts:** Defines comprehensive types for Twitch API responses.
- **Extensive Testing (`test/handlers.ts`, `test/index.test.ts`):** Includes unit tests covering redirection, valid code flow, error handling, refresh/revoke token, custom and built-in state scenarios, using a mock server.
- **Validate Token `src/providers/twitch/validateToken`**: That hit `/validate` endpoint to verify that the access token is still valid for reasons other than token expiring.

View File

@ -918,6 +918,174 @@ app.post('/remove-user', async (c, next) => {
}) })
``` ```
### Twitch
```ts
import { Hono } from 'hono'
import { twitchAuth } from '@hono/oauth-providers/twitch'
const app = new Hono()
app.use(
'/twitch',
twitchAuth({
client_id: Bun.env.TWITCH_ID,
client_secret: Bun.env.TWITCH_SECRET,
scope: ['user:read:email', 'channel:read:subscriptions', 'bits:read'],
redirect_uri: 'http://localhost:3000/twitch',
})
)
export default app
```
#### Parameters
- `client_id`:
- Type: `string`.
- `Required`.
- Your app client ID. You can find this value in the [Twitch Developer Portal](https://dev.twitch.tv/console/apps). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `TWITCH_ID=`.
- `client_secret`:
- Type: `string`.
- `Required`.
- Your app client secret. You can find this value in the [Twitch Developer Portal](https://dev.twitch.tv/console/apps). <br />When developing **Cloudflare Workers**, there's no need to send this parameter. Just declare it in the `wrangler.toml` file as `TWITCH_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 Twitch offers for utilizing their API on the [Twitch API Reference](https://dev.twitch.tv/docs/authentication/scopes).
- `redirect_uri`:
- Type: `string`.
- `Required`.
- The URI to which the user will be redirected after authentication.
- `state`:
- Type: `string`.
- `Optional`.
- A unique string to protect against Cross-Site Request Forgery (CSRF) attacks. The state is passed back to your redirect URI after the user has authenticated. You should verify that the state matches the one you provided in the initial request.
- `force_verify`:
- Type: `boolean`.
- `Optional`.
- Set this value to `true` if you want to force the user to verify their account. Defaults to `false`.
#### Authentication Flow
After the completion of the Twitch OAuth flow, essential data has been prepared for use in the subsequent steps that your app needs to take.
`twitchAuth` method provides 4 set key data:
- `token`:
- Access token to make requests to the Twitch 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 Twitch docs.
- Type:
```
{
token: string
expires_in: number
}
```
- `granted-scopes`:
- Scopes for which the user has granted permissions.
- Type: `string[]`.
- `user-twitch`:
- User basic info retrieved from Twitch
- Type:
```
{
id: string
login: string
display_name: string
type: string
broadcaster_type: string
description: string
profile_image_url: string
offline_image_url: string
view_count: number
email: string
created_at: string
}
```
> [!NOTE]
> To access this data, utilize the `c.get` method within the callback of the upcoming HTTP request handler.
```ts
app.get('/twitch', (c) => {
const token = c.get('token')
const refreshToken = c.get('refresh-token')
const grantedScopes = c.get('granted-scopes')
const user = c.get('user-twitch')
return c.json({
token,
refreshToken,
grantedScopes,
user,
})
})
```
#### Refresh Token
Once the user token expires you can refresh their token without 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 Twitch gives you a new `refresh_token` along with the new token.
```ts
import { twitchAuth, refreshToken } from '@hono/oauth-providers/twitch'
app.post('/twitch/refresh', async (c, next) => {
const newTokens = await refreshToken(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN)
// newTokens = {
// token_type: 'bearer',
// access_token: 'new-access-token',
// expires_in: 60000,
// refresh_token: 'new-refresh-token',
// scope: ['user:read:email', 'channel:read:subscriptions', 'bits:read']
// }
// ...
})
```
#### 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` and the `token` to be revoked as parameters.
It returns a `boolean` to tell whether the token was revoked or not.
```ts
import { twitchAuth, revokeToken } from '@hono/oauth-providers/twitch'
app.post('/remove-user', async (c, next) => {
const revoked = await revokeToken(CLIENT_ID, USER_TOKEN)
// revoked = true | false
// ...
})
```
#### Validate Token
You can validate a Twitch access token to verify it's still valid or to obtain information about the token, such as its expiration date, scopes, and the associated user.
You can use `validateToken` method, which accepts the `token` to be validated as parameter and returns `TwitchValidateSuccess` if valid or throws `HTTPException` upon failure.
> **IMPORTANT:** Twitch requires applications to validate OAuth tokens when they start and on an hourly basis thereafter. Failure to validate tokens may result in Twitch taking punitive action, such as revoking API keys or throttling performance. When a token becomes invalid, your app should terminate all sessions using that token immediately. [Read more](https://dev.twitch.tv/docs/authentication/validate-tokens)
The validation endpoint helps your application detect when tokens become invalid for reasons other than expiration, such as when users disconnect your integration from their Twitch account. When a token becomes invalid, your app should terminate all sessions using that token.
> For security and compliance, make sure to implement regular token validation in your application. If a token becomes invalid, promptly sign out the user and terminate their OAuth session.
## Advance Usage ## Advance Usage
### Customize `redirect_uri` ### Customize `redirect_uri`

View File

@ -83,6 +83,16 @@
"types": "./dist/providers/discord/index.d.ts", "types": "./dist/providers/discord/index.d.ts",
"default": "./dist/providers/discord/index.js" "default": "./dist/providers/discord/index.js"
} }
},
"./twitch" : {
"import": {
"types": "./dist/providers/twitch/index.d.mts",
"default": "./dist/providers/twitch/index.mjs"
},
"require": {
"types": "./dist/providers/twitch/index.d.ts",
"default": "./dist/providers/twitch/index.js"
}
} }
}, },
"typesVersions": { "typesVersions": {
@ -104,6 +114,9 @@
], ],
"discord": [ "discord": [
"./dist/providers/discord/index.d.ts" "./dist/providers/discord/index.d.ts"
],
"twitch": [
"./dist/providers/twitch/index.d.ts"
] ]
} }
}, },

View File

@ -0,0 +1,131 @@
import { HTTPException } from 'hono/http-exception'
import type { Token } from '../../types'
import { toQueryParams } from '../../utils/objectToQuery'
import type {
TwitchErrorResponse,
TwitchUserResponse,
TwitchTokenResponse,
TwitchUser,
Scopes,
} from './types'
type TwitchAuthFlow = {
client_id: string
client_secret: string
redirect_uri: string
scope: Scopes[]
state: string
code: string | undefined
force_verify: boolean | 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<TwitchUser> | undefined
force_verify: boolean | undefined
constructor({
client_id,
client_secret,
redirect_uri,
scope,
state,
code,
force_verify,
}: TwitchAuthFlow) {
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.force_verify = force_verify
this.granted_scopes = undefined
this.user = undefined
}
redirect() {
const parsedOptions = toQueryParams({
client_id: this.client_id,
force_verify: this.force_verify,
redirect_uri: this.redirect_uri,
response_type: 'code',
scope: this.scope,
state: this.state,
})
return `https://id.twitch.tv/oauth2/authorize?${parsedOptions}`
}
private async getTokenFromCode() {
const parsedOptions = toQueryParams({
client_id: this.client_id,
client_secret: this.client_secret,
code: this.code,
grant_type: 'authorization_code',
redirect_uri: this.redirect_uri,
})
const url = 'https://id.twitch.tv/oauth2/token'
const response = (await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: parsedOptions,
}).then((res) => res.json() as Promise<TwitchTokenResponse>))
if ('error' in response) {
throw new HTTPException(400, { message: response.error })
}
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
}
}
async getUserData() {
await this.getTokenFromCode()
const response = (await fetch('https://api.twitch.tv/helix/users', {
headers: {
authorization: `Bearer ${this.token?.token}`,
'Client-ID': this.client_id,
},
}).then((res) => res.json())) as TwitchUserResponse | TwitchErrorResponse
if ('error' in response) {
throw new HTTPException(400, { message: JSON.stringify(response) })
}
if ('message' in response) {
throw new HTTPException(400, { message: JSON.stringify(response) })
}
if ('data' in response) {
this.user = response.data[0]
}
}
}

View File

@ -0,0 +1,13 @@
export { twitchAuth } from './twitchAuth'
export { refreshToken } from './refreshToken'
export { revokeToken } from './revokeToken'
export { validateToken } from './validateToken'
export * from './types'
import type { OAuthVariables } from '../../types'
import type { TwitchUser } from './types'
declare module 'hono' {
interface ContextVariableMap extends OAuthVariables {
'user-twitch': Partial<TwitchUser> | undefined
}
}

View File

@ -0,0 +1,34 @@
import { HTTPException } from 'hono/http-exception'
import { toQueryParams } from '../../utils/objectToQuery'
import type { TwitchRefreshResponse } from './types'
export async function refreshToken(
client_id: string,
client_secret: string,
refresh_token: string
): Promise<TwitchRefreshResponse> {
const params = toQueryParams({
grant_type: 'refresh_token',
refresh_token,
client_id,
client_secret,
})
const response = (await fetch('https://id.twitch.tv/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
}).then((res) => res.json() as Promise<TwitchRefreshResponse>))
if ('error' in response) {
throw new HTTPException(400, { message: response.error })
}
if ('message' in response) {
throw new HTTPException(400, { message: response.message as string })
}
return response
}

View File

@ -0,0 +1,39 @@
import { HTTPException } from 'hono/http-exception'
import { toQueryParams } from '../../utils/objectToQuery'
import type { TwitchRevokingResponse } from './types'
export async function revokeToken(
client_id: string,
token: string
): Promise<boolean> {
const params = toQueryParams({
client_id: client_id,
token,
})
const res = await fetch('https://id.twitch.tv/oauth2/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
})
// Check HTTP status code first
if (!res.ok) {
// Try to parse error response
try {
const errorResponse = await res.json() as TwitchRevokingResponse
if (errorResponse && typeof errorResponse === 'object' && 'message' in errorResponse) {
throw new HTTPException(400, { message: errorResponse.message })
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// If parsing fails, throw a generic error with the status
throw new HTTPException(400, { message: `Token revocation failed with status: ${res.status}` })
}
}
// Success case - Twitch returns 200 with empty body on successful revocation
return true
}

View File

@ -0,0 +1,61 @@
import type { MiddlewareHandler } from 'hono'
import { env } from 'hono/adapter'
import { getCookie, setCookie } 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 twitchAuth(options: {
scope: Scopes[]
client_id: string
client_secret: string
redirect_uri?: string
state?: string
force_verify?: boolean
}): MiddlewareHandler {
return async (c, next) => {
// Generate encoded "keys" if not provided
const newState = options.state || getRandomState()
// Create new Auth instance
const auth = new AuthFlow({
client_id: options.client_id || (env(c).TWITCH_ID as string),
client_secret: options.client_secret || (env(c).TWITCH_SECRET as string),
redirect_uri: options.redirect_uri || c.req.url.split('?')[0],
scope: options.scope,
state: newState,
code: c.req.query('code'),
force_verify: options.force_verify || false,
})
// Redirect to login dialog
if (!auth.code) {
setCookie(c, 'state', newState, {
maxAge: 60 * 10,
httpOnly: true,
path: '/',
})
return c.redirect(auth.redirect())
}
// 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)
}
}
// Retrieve user data from twitch
await auth.getUserData()
// Set return info
c.set('token', auth.token)
c.set('refresh-token', auth.refresh_token)
c.set('user-twitch', auth.user)
c.set('granted-scopes', auth.granted_scopes)
await next()
}
}

View File

@ -0,0 +1,168 @@
export type Scopes =
// Analytics
| 'analytics:read:extensions'
| 'analytics:read:games'
// Bits
| 'bits:read'
// Channel
| 'channel:bot'
| 'channel:manage:ads'
| 'channel:read:ads'
| 'channel:manage:broadcast'
| 'channel:read:charity'
| 'channel:edit:commercial'
| 'channel:read:editors'
| 'channel:manage:extensions'
| 'channel:read:goals'
| 'channel:read:guest_star'
| 'channel:manage:guest_star'
| 'channel:read:hype_train'
| 'channel:manage:moderators'
| 'channel:read:polls'
| 'channel:manage:polls'
| 'channel:read:predictions'
| 'channel:manage:predictions'
| 'channel:manage:raids'
| 'channel:read:redemptions'
| 'channel:manage:redemptions'
| 'channel:manage:schedule'
| 'channel:read:stream_key'
| 'channel:read:subscriptions'
| 'channel:manage:videos'
| 'channel:read:vips'
| 'channel:manage:vips'
| 'channel:moderate'
// Clips
| 'clips:edit'
// User
| 'user:bot'
| 'user:edit'
| 'user:edit:broadcast'
| 'user:read:blocked_users'
| 'user:manage:blocked_users'
| 'user:read:broadcast'
| 'user:read:chat'
| 'user:manage:chat_color'
| 'user:read:email'
| 'user:read:emotes'
| 'user:read:follows'
| 'user:read:moderated_channels'
| 'user:read:subscriptions'
| 'user:read:whispers'
| 'user:manage:whispers'
| 'user:write:chat'
// Moderation
| 'moderation:read'
| 'moderator:manage:announcements'
| 'moderator:manage:automod'
| 'moderator:read:automod_settings'
| 'moderator:manage:automod_settings'
| 'moderator:read:banned_users'
| 'moderator:manage:banned_users'
| 'moderator:read:blocked_terms'
| 'moderator:read:chat_messages'
| 'moderator:manage:blocked_terms'
| 'moderator:manage:chat_messages'
| 'moderator:read:chat_settings'
| 'moderator:manage:chat_settings'
| 'moderator:read:chatters'
| 'moderator:read:followers'
| 'moderator:read:guest_star'
| 'moderator:manage:guest_star'
| 'moderator:read:moderators'
| 'moderator:read:shield_mode'
| 'moderator:manage:shield_mode'
| 'moderator:read:shoutouts'
| 'moderator:manage:shoutouts'
| 'moderator:read:suspicious_users'
| 'moderator:read:unban_requests'
| 'moderator:manage:unban_requests'
| 'moderator:read:vips'
| 'moderator:read:warnings'
| 'moderator:manage:warnings'
// IRC Chat Scopes
| 'chat:edit'
| 'chat:read'
// PubSub-specific Chat Scopes
| 'whispers:read'
// Error responses types from Twitch API
export type TwitchErrorResponse = {
error?: string
error_description?: string
state?: string
message?: string
status?: number
}
export type TwitchValidateError = Required<Pick<TwitchErrorResponse, 'status' | 'message'>>
export type TwitchRevokingError = Required<Pick<TwitchErrorResponse, 'status' | 'message'>>
export type TwitchRefreshError = Required<Pick<TwitchErrorResponse, 'status' | 'message' | 'error'>>
export type TwitchTokenError = Required<Pick<TwitchErrorResponse, 'status' | 'message' | 'error'>>
// Success responses types from Twitch API
export interface TwitchValidateSuccess {
client_id: string
login: string
scopes: Scopes[]
user_id: string
expires_in: number
}
export interface TwitchRevokingSuccess {
status?: number
}
export interface TwitchRefreshSuccess {
access_token: string
expires_in: number
refresh_token: string
scope: Scopes[]
token_type: string
}
export interface TwitchTokenSuccess {
access_token: string
expires_in: number
refresh_token: string
scope: Scopes[]
token_type: string
}
// Combined response types
export type TwitchRevokingResponse = TwitchRevokingSuccess | TwitchRevokingError
export type TwitchRefreshResponse = TwitchRefreshSuccess | TwitchRefreshError
export type TwitchTokenResponse = TwitchTokenSuccess | TwitchTokenError
export type TwitchValidateResponse = TwitchValidateSuccess | TwitchValidateError
export interface TwitchUserResponse {
data: [{
id: string
login: string
display_name: string
type: string
broadcaster_type: string
description: string
profile_image_url: string
offline_image_url: string
view_count: number
email: string
created_at: string
}]
}
export type TwitchUser = TwitchUserResponse['data'][0]

View File

@ -0,0 +1,20 @@
import { HTTPException } from 'hono/http-exception'
import type { TwitchValidateResponse } from './types'
export async function validateToken(
token: string
): Promise<TwitchValidateResponse> {
const response = await fetch('https://id.twitch.tv/oauth2/validate', {
method: 'GET',
headers: {
authorization: `Bearer ${token}`,
},
}).then((res) => res.json() as Promise<TwitchValidateResponse>)
if ('status' in response) {
throw new HTTPException(400, { message: response.message })
}
return response
}

View File

@ -10,6 +10,7 @@ import type {
import type { GitHubErrorResponse, GitHubTokenResponse } from '../src/providers/github' import type { GitHubErrorResponse, GitHubTokenResponse } from '../src/providers/github'
import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from '../src/providers/google' import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from '../src/providers/google'
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin' import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
import type { TwitchErrorResponse, TwitchTokenResponse, TwitchTokenSuccess } from '../src/providers/twitch'
import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x' import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x'
export const handlers = [ export const handlers = [
@ -146,6 +147,55 @@ export const handlers = [
} }
), ),
http.get('https://discord.com/api/oauth2/@me', () => HttpResponse.json(discordUser)), http.get('https://discord.com/api/oauth2/@me', () => HttpResponse.json(discordUser)),
// Twitch
http.post(
'https://id.twitch.tv/oauth2/token',
async ({ request }): Promise<StrictResponse<Partial<TwitchTokenResponse> | TwitchErrorResponse>> => {
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(twitchRefreshTokenError)
}
return HttpResponse.json(twitchRefreshToken)
}
if (code === dummyCode) {
return HttpResponse.json(twitchToken)
}
return HttpResponse.json(twitchCodeError)
}
),
http.get('https://api.twitch.tv/helix/users', () => HttpResponse.json(twitchUser)),
http.post(
'https://id.twitch.tv/oauth2/revoke',
async ({ request }): Promise<StrictResponse<{ status: number; message?: string } | null>> => {
const params = new URLSearchParams(await request.text())
const token = params.get('token')
if (token === 'wrong-token') {
return HttpResponse.json<{ status: number; message?: string }>(twitchRevokeTokenError, { status: 400 })
}
return HttpResponse.json<null>(null, { status: 200 }) // Return 200 with empty body
}
),
// Twitch validate token handler
http.get(
'https://id.twitch.tv/oauth2/validate',
async ({ request }): Promise<StrictResponse<typeof twitchValidateSuccess | typeof twitchValidateError>> => {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return HttpResponse.json(twitchValidateError, { status: 401 })
}
const token = authHeader.split(' ')[1]
if (token === 'twitchr4nd0m4cc3sst0k3n') {
return HttpResponse.json(twitchValidateSuccess)
}
return HttpResponse.json(twitchValidateError, { status: 401 })
}
),
] ]
export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg' export const dummyCode = '4/0AfJohXl9tS46EmTA6u9x3pJQiyCNyahx4DLJaeJelzJ0E5KkT4qJmCtjq9n3FxBvO40ofg'
@ -433,3 +483,67 @@ export const discordRefreshToken = {
export const discordRefreshTokenError = { export const discordRefreshTokenError = {
error: 'Invalid Refresh Token.', error: 'Invalid Refresh Token.',
} }
export const twitchToken: TwitchTokenSuccess = {
access_token: 'twitchr4nd0m4cc3sst0k3n',
expires_in: 14400,
refresh_token: 'twitchr4nd0mr3fr3sht0k3n',
scope: ['user:read:email', 'channel:read:subscriptions', 'bits:read'],
token_type: 'bearer',
}
export const twitchRefreshToken: TwitchTokenResponse = {
access_token: 'twitchn3w4cc3sst0k3n',
expires_in: 14400,
refresh_token: 'twitchn3wr3fr3sht0k3n',
scope: ['user:read:email', 'channel:read:subscriptions', 'bits:read'],
token_type: 'bearer',
}
export const twitchUser = {
data: [
{
id: '12345678',
login: 'younis',
display_name: 'younis.name',
type: '',
broadcaster_type: 'partner',
description: 'Supporting third-party developers building Twitch integrations',
profile_image_url: 'https://static-cdn.jtvnw.net/jtv_user_pictures/example-profile-picture.png',
offline_image_url: 'https://static-cdn.jtvnw.net/jtv_user_pictures/example-offline-image.png',
view_count: 5980557,
email: 'example@twitch.tv',
created_at: '2025-02-14T20:32:28Z',
},
],
}
export const twitchCodeError = {
error: 'access_denied',
error_description: 'Invalid authorization code',
state: 'c3ab8aa609ea11e793ae92361f002671',
}
export const twitchRefreshTokenError = {
error: 'Bad Request',
status: 400,
message: 'Invalid refresh token',
}
export const twitchRevokeTokenError = {
status: 400,
message: 'Token revocation failed with status: 400',
}
export const twitchValidateSuccess = {
client_id: 'wbmytr93xzw8zbg0p1izqyzzc5mbiz',
login: 'younis',
scopes: ['user:read:email', 'channel:read:subscriptions', 'bits:read'],
user_id: '12345678',
expires_in: 14400
}
export const twitchValidateError = {
status: 401,
message: 'invalid access token'
}

View File

@ -14,6 +14,13 @@ import { googleAuth } from '../src/providers/google'
import type { GoogleUser } from '../src/providers/google' import type { GoogleUser } from '../src/providers/google'
import { linkedinAuth } from '../src/providers/linkedin' import { linkedinAuth } from '../src/providers/linkedin'
import type { LinkedInUser } from '../src/providers/linkedin' import type { LinkedInUser } from '../src/providers/linkedin'
import type { TwitchUser } from '../src/providers/twitch'
import {
twitchAuth,
refreshToken as twitchRefresh,
revokeToken as twitchRevoke,
validateToken as twitchValidate
} from '../src/providers/twitch'
import type { XUser } from '../src/providers/x' import type { XUser } from '../src/providers/x'
import { refreshToken, revokeToken, xAuth } from '../src/providers/x' import { refreshToken, revokeToken, xAuth } from '../src/providers/x'
import type { Token } from '../src/types' import type { Token } from '../src/types'
@ -42,6 +49,14 @@ import {
xRevokeTokenError, xRevokeTokenError,
xToken, xToken,
xUser, xUser,
twitchCodeError,
twitchRefreshToken,
twitchRefreshTokenError,
twitchRevokeTokenError,
twitchToken,
twitchUser,
twitchValidateSuccess,
twitchValidateError,
} from './handlers' } from './handlers'
const server = setupServer(...handlers) const server = setupServer(...handlers)
@ -340,6 +355,76 @@ describe('OAuth Middleware', () => {
return c.json(response) return c.json(response)
}) })
// Twitch
app.use(
'/twitch',
twitchAuth({
client_id,
client_secret,
scope: ['user:read:email', 'channel:read:subscriptions', 'bits:read'],
redirect_uri: 'http://localhost:3000/twitch', // Redirect URI
})
)
app.use('/twitch-custom-redirect', (c, next) =>
twitchAuth({
client_id,
client_secret,
scope: ['user:read:email'],
redirect_uri: 'http://localhost:3000/twitch',
})(c, next)
)
app.use('/twitch-force-verify', (c, next) =>
twitchAuth({
client_id,
client_secret,
scope: ['user:read:email'],
redirect_uri: 'http://localhost:3000/twitch',
force_verify: true,
})(c, next)
)
app.use('/twitch-custom-state', (c, next) =>
twitchAuth({
client_id,
client_secret,
scope: ['user:read:email'],
redirect_uri: 'http://localhost:3000/twitch',
state: 'test-state',
})(c, next)
)
app.get('/twitch', (c) => {
const token = c.get('token')
const refreshToken = c.get('refresh-token')
const user = c.get('user-twitch')
const grantedScopes = c.get('granted-scopes')
return c.json({
token,
refreshToken,
grantedScopes,
user,
})
})
app.get('/twitch/refresh', async (c) => {
const response = await twitchRefresh(
client_id,
client_secret,
'twitchr4nd0mr3fr3sht0k3n'
)
return c.json(response)
})
app.get('/twitch/refresh/error', async (c) => {
const response = await twitchRefresh(client_id, client_secret, 'wrong-refresh-token')
return c.json(response)
})
app.get('/twitch/revoke', async (c) => {
const response = await twitchRevoke(client_id, 'twitchr4nd0m4cc3sst0k3n')
return c.json(response)
})
app.get('/twitch/revoke/error', async (c) => {
const response = await twitchRevoke(client_id, 'wrong-token')
return c.json(response)
})
beforeAll(() => { beforeAll(() => {
server.listen() server.listen()
}) })
@ -771,4 +856,125 @@ describe('OAuth Middleware', () => {
}) })
}) })
}) })
describe('twitchAuth middleware', () => {
it('Should work with custom state', async () => {
const res = await app.request('/twitch-custom-state')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
const redirectLocation = res.headers.get('location')!
const redirectUrl = new URL(redirectLocation)
expect(redirectUrl.searchParams.get('state')).toBe('test-state')
})
})
describe('twitchAuth middleware', () => {
describe('middleware', () => {
it('Should redirect', async () => {
const res = await app.request('/twitch')
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
it('Should redirect to custom redirect_uri', async () => {
const res = await app.request('/twitch-custom-redirect')
expect(res?.status).toBe(302)
const redirectLocation = res.headers.get('location')!
const redirectUrl = new URL(redirectLocation)
expect(redirectUrl.searchParams.get('redirect_uri')).toBe('http://localhost:3000/twitch')
})
it('Should include force_verify when enabled', async () => {
const res = await app.request('/twitch-force-verify')
expect(res?.status).toBe(302)
const redirectLocation = res.headers.get('location')!
const redirectUrl = new URL(redirectLocation)
expect(redirectUrl.searchParams.get('force_verify')).toBe('true')
})
it('Prevent CSRF attack', async () => {
const res = await app.request(`/twitch?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('/twitch?code=9348ffdsd-sdsdbad-code')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(twitchCodeError.error)
})
it('Should work with received code', async () => {
const res = await app.request(`/twitch?code=${dummyCode}`)
const response = (await res.json()) as {
token: Token
refreshToken: Token
user: TwitchUser
grantedScopes: string[]
}
expect(res).not.toBeNull()
expect(res.status).toBe(200)
expect(response.user).toEqual(twitchUser.data[0])
expect(response.grantedScopes).toEqual(twitchToken.scope)
expect(response.token).toEqual({
token: twitchToken.access_token,
expires_in: twitchToken.expires_in,
})
expect(response.refreshToken).toEqual({
token: twitchToken.refresh_token,
expires_in: 0,
})
})
})
describe('Refresh Token', () => {
it('Should refresh token', async () => {
const res = await app.request('/twitch/refresh')
expect(res).not.toBeNull()
expect(await res.json()).toEqual(twitchRefreshToken)
})
it('Should return error for refresh', async () => {
const res = await app.request('/twitch/refresh/error')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(twitchRefreshTokenError.error)
})
})
describe('Revoke Token', () => {
it('Should revoke token', async () => {
const res = await app.request('/twitch/revoke')
expect(res).not.toBeNull()
expect(await res.json()).toEqual(true)
})
it('Should return error for revoke', async () => {
const res = await app.request('/twitch/revoke/error')
expect(res).not.toBeNull()
expect(res.status).toBe(400)
expect(await res.text()).toBe(twitchRevokeTokenError.message)
})
})
describe('Validate Token', () => {
it('Should validate a valid token', async () => {
const res = await twitchValidate('twitchr4nd0m4cc3sst0k3n')
expect(res).toEqual(twitchValidateSuccess)
})
it('Should throw error for invalid token', async () => {
const res = twitchValidate('invalid-token')
await expect(res).rejects.toThrow(twitchValidateError.message)
})
})
})
}) })