├── .env.template ├── .eslintrc.json ├── app ├── favicon.ico ├── utils.ts ├── layout.tsx ├── globals.css ├── api │ └── auth │ │ ├── logout │ │ └── route.ts │ │ └── callback │ │ └── route.ts ├── dashboard │ └── page.tsx └── page.tsx ├── public └── logo.png ├── next.config.js ├── postcss.config.js ├── renovate.json ├── .gitignore ├── tailwind.config.ts ├── package.json ├── tsconfig.json ├── LICENSE ├── middleware.ts └── README.md /.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_DESCOPE_PROJECT_ID= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/descope-sample-apps/oidc-client-sample-app/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/descope-sample-apps/oidc-client-sample-app/HEAD/public/logo.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>descope-sample-apps/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /app/utils.ts: -------------------------------------------------------------------------------- 1 | if (process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID === undefined) throw new Error('NEXT_PUBLIC_DESCOPE_PROJECT_ID is not defined'); 2 | 3 | const client_id: string = process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID; 4 | 5 | const API_URL = process.env.NODE_ENV === 'production' ? 'https://oidc-client-sample-app.preview.descope.org' : 'http://localhost:3000'; 6 | 7 | export { client_id, API_URL } -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import './globals.css' 4 | 5 | const inter = Inter({ subsets: ['latin'] }) 6 | 7 | export const metadata: Metadata = { 8 | title: 'Descope OIDC Sample App', 9 | description: 'Showcasing OIDC with Descope', 10 | } 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 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 | "jwt-decode": "^4.0.0", 13 | "next": "14.2.32", 14 | "react": "^18", 15 | "react-dom": "^18" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^22.0.0", 19 | "@types/react": "^19.0.0", 20 | "@types/react-dom": "^19.0.0", 21 | "autoprefixer": "^10.0.1", 22 | "eslint": "^9.0.0", 23 | "eslint-config-next": "15.4.6", 24 | "postcss": "^8", 25 | "tailwindcss": "^3.3.0", 26 | "typescript": "^5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "downlevelIteration": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/api/auth/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { API_URL, client_id } from "@/app/utils"; 2 | import { cookies } from "next/headers"; 3 | 4 | export async function GET() { 5 | const id_token = cookies().get('id_token')?.value; 6 | 7 | cookies().set('id_token', '', { expires: new Date(0) }); 8 | cookies().set('access_token', '', { expires: new Date(0) }); 9 | cookies().set('refresh_token', '', { expires: new Date(0) }); 10 | cookies().set('expires_in', '', { expires: new Date(0) }); 11 | 12 | let baseURL = "api.descope.com" 13 | if (process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID && process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.length >= 32) { 14 | const localURL = process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.substring(1, 5) 15 | baseURL = [baseURL.slice(0, 4), localURL, ".", baseURL.slice(4)].join('') 16 | } 17 | const logoutUrl = `https://${baseURL}/oauth2/v1/logout?id_token_hint=${id_token}&post_logout_redirect_uri=` + API_URL; 18 | return new Response(null, { 19 | status: 302, 20 | headers: { 21 | 'Location': logoutUrl, 22 | } 23 | }); 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Descope Sample Apps 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { jwtDecode } from 'jwt-decode'; 2 | import { NextResponse } from 'next/server' 3 | import type { NextRequest } from 'next/server' 4 | 5 | export function middleware(request: NextRequest) { 6 | const pathName = request.nextUrl.pathname; 7 | let access_token = request.cookies.get('access_token')?.value; 8 | 9 | // In production you should validate the access token here (signature, etc). 10 | // We simply check the expiration date 11 | 12 | if (pathName === '/' && access_token && !isJwtExpired(access_token)) { 13 | return NextResponse.redirect(new URL('/dashboard', request.url)) 14 | } 15 | if (pathName === '/dashboard' && (!access_token || isJwtExpired(access_token))) { 16 | return NextResponse.redirect(new URL('/', request.url)) 17 | } 18 | 19 | return NextResponse.next(); 20 | } 21 | 22 | export const config = { 23 | matcher: [ 24 | /* 25 | * Match all request paths except for the ones starting with: 26 | * - api (API routes) 27 | * - _next/static (static files) 28 | * - _next/image (image optimization files) 29 | * - favicon.ico (favicon file) 30 | */ 31 | '/((?!api|_next/static|_next/image|favicon.ico).*)', 32 | ], 33 | } 34 | 35 | const isJwtExpired = (token: string) => { 36 | if (typeof token !== 'string' || !token) throw Error('Invalid token provided') 37 | const { exp } = jwtDecode(token); 38 | const currentTime = new Date().getTime() / 1000; 39 | return exp && exp < currentTime; 40 | } -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | export default function Page() { 4 | // Example user info request for use in route handler 5 | const getUserInfo = async (access_token: string) => { 6 | let baseURL = "api.descope.com" 7 | if (process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID && process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.length >= 32) { 8 | const localURL = process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.substring(1, 5) 9 | baseURL = [baseURL.slice(0, 4), localURL, ".", baseURL.slice(4)].join('') 10 | } 11 | const userInfoUrl = `https://${baseURL}/oauth2/v1/userinfo`; 12 | 13 | const res = await fetch(userInfoUrl, { 14 | method: 'GET', 15 | headers: { 16 | 'Authorization': `Bearer ${access_token}` 17 | }, 18 | }) 19 | if (!res.ok) { 20 | console.log("Error getting user info") 21 | return; 22 | } 23 | const data = await res.json(); 24 | console.log(data); 25 | } 26 | 27 | 28 | return
29 |

You are logged in!

30 |

Check the cookies for your current domain and you should see an `id_token`, `refresh_token`, `access_token`, and `expires_in` set.

31 | 36 |
37 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a sample app showcasing authentication via OIDC using Descope's hosted authentication flow page. It's written in NextJS. 2 | 3 | ## Getting Started 4 | 5 | 6 | First, add your [Descope Project ID](https://app.descope.com/settings/project) to a `.env` file in the root folder. 7 | 8 | ``` 9 | NEXT_PUBLIC_DESCOPE_PROJECT_ID= 10 | ``` 11 | 12 | 13 | Second, install packages, and run the development server: 14 | 15 | ```bash 16 | npm i 17 | npm run dev 18 | ``` 19 | 20 | Now, Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 21 | 22 | ## Features 23 | ### Log in via OIDC 24 | 25 | This entails redirecting to a Descope OIDC authorize endpoint which includes: 26 | 1. A code challenge and verifier, both cryptographically random strings for challenge-response validation 27 | 2. Your [Descope Project ID](https://app.descope.com/settings/project) 28 | 3. A callback url. This url is defined in a Route Handler in your project at `/api/auth/callback` and parses the code verifier and token returned via Descope OIDC after successful authentication. Then, it sets the `id_token`, `access_token`, `refresh_token`, and `expires_in` to cookies before redirecting to the dashboard. 29 | 30 | ### Log out via OIDC 31 | 32 | This entails redirecting to `/api/auth/logout` which then fetches the `id_token` from the cookies before clearing them. Then it redirects to Descope's OIDC logout endpoint with the `id_token` as a parameter along with the url to return to after successful log out. 33 | 34 | ### Middleware for Routing 35 | NextJS middleware that handles redirect between `/dashboard` and `/` depending on if a valid `access_token` exists in cookies. 36 | -------------------------------------------------------------------------------- /app/api/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { client_id } from "@/app/utils"; 2 | import { cookies } from "next/headers"; 3 | 4 | export async function GET(request: Request) { 5 | const { searchParams } = new URL(request.url); 6 | const code = searchParams.get('code'); 7 | const state = searchParams.get('state'); 8 | 9 | let baseURL = "api.descope.com" 10 | if (process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID && process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.length >= 32) { 11 | const localURL = process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.substring(1, 5) 12 | baseURL = [baseURL.slice(0, 4), localURL, ".", baseURL.slice(4)].join('') 13 | } 14 | const tokenUrl = `https://${baseURL}/oauth2/v1/token`; 15 | 16 | const tokenData = { 17 | grant_type: 'authorization_code', 18 | code: code, 19 | client_id: client_id, 20 | code_verifier: state, 21 | }; 22 | 23 | const res = await fetch(tokenUrl, { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | }, 28 | body: JSON.stringify(tokenData), 29 | }) 30 | const data = await res.json() 31 | 32 | const { access_token, id_token, refresh_token, expires_in } = data; 33 | 34 | cookies().set('id_token', id_token, { httpOnly: true, secure: true, sameSite: 'lax' }) 35 | cookies().set('access_token', access_token, { httpOnly: true, secure: true, sameSite: 'lax' }) 36 | cookies().set('refresh_token', refresh_token, { httpOnly: true, secure: true, sameSite: 'lax' }) 37 | cookies().set('expires_in', expires_in, { httpOnly: true, secure: true, sameSite: 'lax' }) 38 | 39 | 40 | return new Response(null, { 41 | status: 302, 42 | headers: { 43 | 'Location': '/dashboard', 44 | } 45 | }); 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { API_URL, client_id } from "./utils"; 4 | 5 | export default function Home() { 6 | function generateCodeVerifier() { 7 | let result = ''; 8 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; 9 | const charactersLength = characters.length; 10 | 11 | for (let i = 0; i < 128; i++) { 12 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 13 | } 14 | 15 | return result; 16 | } 17 | 18 | function generateCodeChallenge(verifier: string) { 19 | return crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) 20 | .then(arrayBuffer => { 21 | const base64Url = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))) 22 | .replace(/=/g, '') 23 | .replace(/\+/g, '-') 24 | .replace(/\//g, '_'); 25 | return base64Url; 26 | }); 27 | } 28 | 29 | 30 | const getAuthUrl = async () => { 31 | const codeVerifier = generateCodeVerifier(); 32 | const codeChallenge = await generateCodeChallenge(codeVerifier); 33 | let baseURL = "api.descope.com" 34 | if (process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID && process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.length >= 32) { 35 | const localURL = process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID.substring(1, 5) 36 | baseURL = [baseURL.slice(0, 4), localURL, ".", baseURL.slice(4)].join('') 37 | } 38 | 39 | const redirect_uri = API_URL + '/api/auth/callback'; 40 | const authUrl = `https://${baseURL}/oauth2/v1/authorize?response_type=code&client_id=${client_id}&redirect_uri=${redirect_uri}&scope=openid&code_challenge=${codeChallenge}&code_challenge_method=S256&state=${codeVerifier}`; 41 | console.log(authUrl); 42 | 43 | return authUrl; 44 | } 45 | 46 | const onClick = async () => { 47 | const authUrl = await getAuthUrl(); 48 | window.location.href = authUrl; 49 | } 50 | 51 | 52 | return ( 53 |
54 |

Welcome to Descope's OIDC Sample App

55 | 56 | 57 |
58 | ) 59 | } 60 | 61 | 62 | 63 | 64 | 65 | --------------------------------------------------------------------------------