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
parent
4d67af162f
commit
e5f383787c
|
@ -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.
|
|
@ -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
|
||||
|
||||
### Customize `redirect_uri`
|
||||
|
|
|
@ -83,6 +83,16 @@
|
|||
"types": "./dist/providers/discord/index.d.ts",
|
||||
"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": {
|
||||
|
@ -104,6 +114,9 @@
|
|||
],
|
||||
"discord": [
|
||||
"./dist/providers/discord/index.d.ts"
|
||||
],
|
||||
"twitch": [
|
||||
"./dist/providers/twitch/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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]
|
|
@ -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
|
||||
}
|
|
@ -10,6 +10,7 @@ import type {
|
|||
import type { GitHubErrorResponse, GitHubTokenResponse } from '../src/providers/github'
|
||||
import type { GoogleErrorResponse, GoogleTokenResponse, GoogleUser } from '../src/providers/google'
|
||||
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'
|
||||
|
||||
export const handlers = [
|
||||
|
@ -146,6 +147,55 @@ export const handlers = [
|
|||
}
|
||||
),
|
||||
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'
|
||||
|
@ -433,3 +483,67 @@ export const discordRefreshToken = {
|
|||
export const discordRefreshTokenError = {
|
||||
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'
|
||||
}
|
||||
|
|
|
@ -14,6 +14,13 @@ 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 { 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 { refreshToken, revokeToken, xAuth } from '../src/providers/x'
|
||||
import type { Token } from '../src/types'
|
||||
|
@ -42,6 +49,14 @@ import {
|
|||
xRevokeTokenError,
|
||||
xToken,
|
||||
xUser,
|
||||
twitchCodeError,
|
||||
twitchRefreshToken,
|
||||
twitchRefreshTokenError,
|
||||
twitchRevokeTokenError,
|
||||
twitchToken,
|
||||
twitchUser,
|
||||
twitchValidateSuccess,
|
||||
twitchValidateError,
|
||||
} from './handlers'
|
||||
|
||||
const server = setupServer(...handlers)
|
||||
|
@ -340,6 +355,76 @@ describe('OAuth Middleware', () => {
|
|||
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(() => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue