├── .eslintrc.json ├── public ├── favicon.ico └── vercel.svg ├── next.config.js ├── next-env.d.ts ├── components ├── LoginError.tsx ├── OnlineUsers.tsx └── ChatView.tsx ├── .gitignore ├── tsconfig.json ├── pages ├── _document.tsx ├── index.tsx ├── _app.tsx ├── chat.tsx └── api │ └── login.ts ├── package.json ├── utils └── useCache.ts └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ARE/pubnub-auth0-integration/main/public/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /components/LoginError.tsx: -------------------------------------------------------------------------------- 1 | import Alert from '@mui/material/Alert' 2 | import Stack from '@mui/material/Stack' 3 | import Link from 'next/link' 4 | 5 | export default function LoginError() { 6 | return ( 7 | 8 | 9 | You must be logged in to see this page! 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | 15 | # production 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | import { CssBaseline } from '@mui/material' 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from '@auth0/auth0-react' 2 | 3 | import { Button, Icon, Stack } from '@mui/material' 4 | import Typography from '@mui/material/Typography' 5 | 6 | export default function Home() { 7 | const { loginWithRedirect } = useAuth0() 8 | 9 | return ( 10 | 11 | 12 | PubNub + Auth0 13 | 14 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from '@mui/material' 2 | import type { AppProps } from 'next/app' 3 | import Head from 'next/head' 4 | 5 | import { Auth0Provider } from '@auth0/auth0-react' 6 | 7 | export default function MyApp({ Component, pageProps }: AppProps) { 8 | let uri 9 | 10 | if (typeof window !== 'undefined') { 11 | uri = window?.location?.origin 12 | } else { 13 | uri = 'localhost:3000' 14 | } 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@auth0/auth0-react": "^1.10.2", 13 | "@emotion/react": "^11.10.0", 14 | "@emotion/styled": "^11.10.0", 15 | "@mui/material": "^5.9.3", 16 | "@types/auth0": "^2.35.3", 17 | "auth0": "^2.42.0", 18 | "express-jwt": "^7.7.5", 19 | "jose": "^4.8.3", 20 | "jwks-rsa": "^2.1.4", 21 | "next": "12.2.3", 22 | "pubnub": "^7.2.0", 23 | "pubnub-react": "^3.0.1", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "18.6.3", 29 | "@types/pubnub": "^7.2.0", 30 | "@types/react": "18.0.15", 31 | "@types/react-dom": "18.0.6", 32 | "eslint": "8.21.0", 33 | "eslint-config-next": "12.2.3", 34 | "typescript": "4.7.4" 35 | }, 36 | "prettier": { 37 | "semi": false, 38 | "singleQuote": true, 39 | "printWidth": 120 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /utils/useCache.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from 'react' 2 | 3 | export type GetCacheEntry = (key: string) => Promise 4 | 5 | export type Cache = { 6 | has(key: string): boolean 7 | get(key: string): T | undefined 8 | set(key: string, entry: T): T 9 | } 10 | 11 | const fetching = new Set() 12 | 13 | export default function useCache(getCacheEntry: GetCacheEntry) { 14 | const [cache, updateCache] = useState>(new Map()) 15 | 16 | return useMemo( 17 | () => ({ 18 | has(key: string) { 19 | return cache.has(key) 20 | }, 21 | get(key: string) { 22 | async function fetchEntry(key: string) { 23 | fetching.add(key) 24 | const entry = await getCacheEntry(key) 25 | 26 | fetching.delete(key) 27 | updateCache(() => new Map(cache.set(key, entry))) 28 | } 29 | 30 | if (cache.has(key)) { 31 | return cache.get(key) 32 | } else { 33 | if (!fetching.has(key)) { 34 | fetchEntry(key).catch(console.error) 35 | } 36 | 37 | return undefined 38 | } 39 | }, 40 | set(key: string, entry: T) { 41 | updateCache(() => new Map(cache.set(key, entry))) 42 | 43 | return entry 44 | }, 45 | }), 46 | [cache, getCacheEntry] 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /components/OnlineUsers.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Grid, Paper, styled, Typography } from '@mui/material' 2 | import { Cache } from '../utils/useCache' 3 | 4 | const Item = styled(Paper)(({ theme }) => ({ 5 | backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff', 6 | ...theme.typography.body2, 7 | padding: theme.spacing(1), 8 | })) 9 | 10 | export default function OnlineUsers({ cache, onlineUserIds }: { cache: Cache; onlineUserIds: string[] }) { 11 | return ( 12 | <> 13 | 14 | Online Users 15 | 16 | 17 | {onlineUserIds.map((userId) => { 18 | const userData = cache.get(userId) 19 | 20 | if (!userData) { 21 | return null 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {userData.name} 33 | 34 | 35 | 36 | 37 | ) 38 | })} 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /pages/chat.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from '@auth0/auth0-react' 2 | import { Avatar, Box, Button, Card, CircularProgress, Grid, Icon, Paper, styled, Typography } from '@mui/material' 3 | import Stack from '@mui/material/Stack' 4 | 5 | import { PubNubProvider } from 'pubnub-react' 6 | 7 | import Head from 'next/head' 8 | import LoginError from '../components/LoginError' 9 | import { useEffect, useMemo, useState } from 'react' 10 | import Pubnub from 'pubnub' 11 | import ChatView from '../components/ChatView' 12 | 13 | export default function ChatPage() { 14 | const { isAuthenticated, isLoading, user, getAccessTokenWithPopup, getAccessTokenSilently } = useAuth0() 15 | const [isReady, setReady] = useState(false) 16 | 17 | const pubnub = useMemo(() => { 18 | if (isAuthenticated && user?.sub && !isLoading) { 19 | return new Pubnub({ 20 | subscribeKey: process.env.NEXT_PUBLIC_PN_SUB_KEY!, 21 | publishKey: process.env.NEXT_PUBLIC_PN_PUB_KEY!, 22 | uuid: user.sub, 23 | }) 24 | } 25 | }, [isAuthenticated, isLoading, user?.sub]) 26 | 27 | useEffect(() => { 28 | async function fetchToken() { 29 | if (isAuthenticated && !isLoading && pubnub) { 30 | let token 31 | 32 | try { 33 | token = await getAccessTokenSilently({ audience: `http://localhost:3000/api` }) 34 | } catch (e) { 35 | token = await getAccessTokenWithPopup({ audience: `http://localhost:3000/api` }) 36 | } 37 | 38 | const res = await fetch(new URL('/api/login', window.location.origin), { 39 | method: 'GET', 40 | headers: { 41 | authorization: `Bearer ${token}`, 42 | }, 43 | }) 44 | 45 | const result = await res.json() 46 | 47 | if (result.success === true) { 48 | pubnub.setToken(result.token) 49 | setReady(true) 50 | } 51 | } 52 | } 53 | 54 | fetchToken().catch(console.error) 55 | }, [isAuthenticated, isLoading, getAccessTokenWithPopup, getAccessTokenSilently, pubnub]) 56 | 57 | if (isLoading) { 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | ) 65 | } 66 | 67 | if (!isAuthenticated && !isLoading) { 68 | return ( 69 | <> 70 | 71 | Chat - PubNub + Auth0 72 | 73 | 74 | 75 | 76 | ) 77 | } 78 | 79 | return ( 80 | 81 | 82 | Chat - PubNub + Auth0 83 | 84 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /pages/api/login.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | import { expressjwt } from 'express-jwt' 3 | import { expressJwtSecret } from 'jwks-rsa' 4 | import { ManagementClient } from 'auth0' 5 | 6 | import Pubnub from 'pubnub' 7 | 8 | const port = process.env.PORT || 8080 9 | 10 | const jwtCheck = expressjwt({ 11 | secret: expressJwtSecret({ 12 | cache: true, 13 | rateLimit: true, 14 | jwksRequestsPerMinute: 5, 15 | jwksUri: 'https://dev-go9eq5m8.us.auth0.com/.well-known/jwks.json', 16 | }) as any, 17 | audience: `http://localhost:3000/api`, 18 | issuer: 'https://dev-go9eq5m8.us.auth0.com/', 19 | algorithms: ['RS256'], 20 | }) 21 | 22 | const auth0 = new ManagementClient({ 23 | domain: 'dev-go9eq5m8.us.auth0.com', 24 | clientId: 'dN7iWMU29VjQf9zAaxOFKuycawjAKwsZ', 25 | clientSecret: 'IrM3HrdxbvPRJjAg9okGWAgW7B9m1IgsOeUf1F2OaLI0pQGBJL9yViggj7p0kZLV', 26 | scope: 'read:users', 27 | }) 28 | 29 | function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) { 30 | return new Promise((resolve, reject) => { 31 | fn(req, res, (result: any) => { 32 | if (result instanceof Error) { 33 | return reject(result) 34 | } 35 | 36 | return resolve(result) 37 | }) 38 | }) 39 | } 40 | 41 | const pubnub = new Pubnub({ 42 | publishKey: process.env.NEXT_PUBLIC_PN_PUB_KEY!, 43 | subscribeKey: process.env.NEXT_PUBLIC_PN_SUB_KEY!, 44 | secretKey: process.env.PN_SEC_KEY!, 45 | userId: 'server', 46 | }) 47 | 48 | export default async function handler(req: NextApiRequest & { auth: { sub: string } }, res: NextApiResponse) { 49 | try { 50 | await runMiddleware(req, res, jwtCheck) 51 | } catch (e) { 52 | console.error(e) 53 | return res.status(403).json({ success: false, error: 'Unauthenticated' }) 54 | } 55 | 56 | const userId = req.auth.sub 57 | 58 | const userData = await auth0.getUser({ id: userId }) 59 | 60 | try { 61 | const token = await pubnub.grantToken({ 62 | authorized_uuid: userId, 63 | ttl: 60, 64 | resources: { 65 | channels: { 66 | global: { read: true, write: true }, 67 | 'global-pnpres': { read: true, write: true }, 68 | }, 69 | uuids: { 70 | [userId]: { update: true, get: true }, 71 | }, 72 | }, 73 | patterns: { 74 | uuids: { 75 | '.*': { get: true }, 76 | }, 77 | }, 78 | }) 79 | 80 | await pubnub.objects.setUUIDMetadata({ 81 | uuid: userId, 82 | data: { 83 | profileUrl: userData.user_metadata?.profile ?? userData.picture, 84 | email: userData.email, 85 | name: userData.name, 86 | }, 87 | }) 88 | 89 | return res.status(200).json({ success: true, token: token }) 90 | } catch (e) { 91 | console.error(e) 92 | return res.status(500).json({ success: false, error: 'Grant failed' }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /components/ChatView.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from '@auth0/auth0-react' 2 | import { Avatar, Button, CircularProgress, Icon, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material' 3 | import Grid from '@mui/material/Grid' 4 | import { MessageEvent, PresenceEvent } from 'pubnub' 5 | import { usePubNub } from 'pubnub-react' 6 | import { useEffect, useRef, useState } from 'react' 7 | import useCache from '../utils/useCache' 8 | import OnlineUsers from './OnlineUsers' 9 | 10 | export default function ChatView({ ready, userId }: { ready: boolean; userId: string }) { 11 | const { logout } = useAuth0() 12 | const [onlineUsers, setOnlineUsers] = useState([]) 13 | const [messages, setMessages] = useState([]) 14 | const [newMessage, setNewMessage] = useState('') 15 | const chatboxRef = useRef() 16 | 17 | const pubnub = usePubNub() 18 | 19 | const cache = useCache(async (key: string) => { 20 | const result = await pubnub.objects.getUUIDMetadata({ uuid: key, include: { customFields: true } }) 21 | 22 | return result.data 23 | }) 24 | 25 | useEffect(() => { 26 | async function setup() { 27 | const listener = { 28 | message(messageEvent: MessageEvent) { 29 | setMessages((m) => [messageEvent, ...m]) 30 | if (chatboxRef.current) { 31 | chatboxRef.current.scrollIntoView(false) 32 | } 33 | }, 34 | presence(presenceEvent: PresenceEvent) { 35 | switch (presenceEvent.action) { 36 | case 'join': 37 | setOnlineUsers((u) => [...u, presenceEvent.uuid]) 38 | break 39 | case 'leave': 40 | setOnlineUsers((u) => u.filter((i) => i !== presenceEvent.uuid)) 41 | break 42 | default: 43 | console.log(presenceEvent) 44 | } 45 | }, 46 | } 47 | 48 | if (ready) { 49 | setOnlineUsers([]) 50 | setMessages([]) 51 | 52 | pubnub.addListener(listener) 53 | 54 | pubnub.subscribe({ channels: ['global'], withPresence: true }) 55 | 56 | const result = await pubnub.hereNow({ channels: ['global'] }) 57 | 58 | if (result.totalOccupancy > 0) { 59 | setOnlineUsers((u) => [...result.channels['global'].occupants.map((o) => o.uuid)]) 60 | } 61 | } 62 | 63 | return () => { 64 | pubnub.removeListener(listener) 65 | 66 | pubnub.unsubscribe({ channels: ['global'] }) 67 | } 68 | } 69 | 70 | setup().catch(console.error) 71 | }, [pubnub, ready]) 72 | 73 | const onChange = (event: any) => { 74 | setNewMessage(event.target.value) 75 | } 76 | 77 | const onKeyUp = async (event: any) => { 78 | if (event.key === 'Enter') { 79 | setNewMessage('') 80 | await pubnub.publish({ channel: 'global', message: newMessage }) 81 | } 82 | } 83 | 84 | const userData = ready ? cache.get(userId) : undefined 85 | 86 | return ( 87 | 88 | 89 | 90 | PubNub + Auth0 91 | 92 | 100 | {userData ? ( 101 | 102 | ) : ( 103 | 104 | 105 | 106 | )} 107 | 108 | 109 | 110 | 111 | 112 | 123 | {messages.map((messageEvent) => ( 124 | 125 | 126 | {cache.get(messageEvent.publisher)?.name ?? } 127 | 128 | {messageEvent.message} 129 | 130 | 131 | ))} 132 | 133 | 134 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | ) 150 | } 151 | --------------------------------------------------------------------------------