├── src ├── middleware.ts ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── app │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ └── page.tsx └── auth.ts ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── next.config.ts ├── renovate.json ├── postcss.config.mjs ├── tailwind.config.ts ├── .env.local.example ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /src/middleware.ts: -------------------------------------------------------------------------------- 1 | export { auth as middleware } from "@/auth"; 2 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/descope-sample-apps/3rd-party-sample-app/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; // Referring to the auth.ts we just created 2 | export const { GET, POST } = handlers -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>descope-sample-apps/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } satisfies Config; 19 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | AUTH_SECRET="" # Added by `npx auth`. Read more: https://cli.authjs.dev 2 | CLIENT_SECRET="" # The client Secret of the configured 3rd party application within Descope. 3 | CLIENT_ID="" # The client ID of the configured 3rd party application within Descope. 4 | BASE_URL="https://api.descope.com" # The custom CNAME URL of your Descope project. If not configured, leave as is. 5 | PROJECT_ID="" # The project ID of your Descope project. 6 | SCOPES="openid" # The scope of the user. If not configured, leave as is. 7 | CONSENT_SCOPES="" # The consent scope to validate for the user, these would be configured under permission scopes within Descope. If not configured, leave as is. -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | yarn.lock 43 | package-lock.json -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 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 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth-apps", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "15.4.10", 13 | "next-auth": "^5.0.0-beta.25", 14 | "react": "^18", 15 | "react-dom": "^18" 16 | }, 17 | "devDependencies": { 18 | "@stylistic/eslint-plugin-ts": "^2.11.0", 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "eslint": "^8", 23 | "eslint-config-next": "15.0.3", 24 | "postcss": "^8", 25 | "tailwindcss": "^3.4.1", 26 | "typescript": "^5" 27 | }, 28 | "packageManager": "yarn@4.5.3" 29 | } 30 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { SessionProvider } from "next-auth/react"; 3 | import { Geist, Geist_Mono } from "next/font/google"; 4 | import "./globals.css"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Create Next App", 8 | description: "Generated by create next app", 9 | }; 10 | 11 | const geist = Geist({ subsets: ["latin"] }); 12 | const geistMono = Geist_Mono({ subsets: ["latin"] }); 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/api/app/route.ts: -------------------------------------------------------------------------------- 1 | import { auth, baseUrl, clientId, clientSecret, consentScopes } from "@/auth"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export const GET = auth(async function GET(req) { 5 | if (req.auth) { 6 | const body = "grant_type=client_credentials" + 7 | "&client_id=" + clientId + 8 | "&client_secret=" + clientSecret + 9 | "&scope=" + consentScopes; 10 | const res = await fetch(`${baseUrl}/oauth2/v1/apps/token`, { 11 | method: "POST", 12 | headers: { 13 | "content-type": "application/x-www-form-urlencoded", 14 | }, 15 | body, 16 | }); 17 | return NextResponse.json(await res.json(), { status: res.status }); 18 | } 19 | return NextResponse.json({ message: "Not authenticated" }, { status: 401 }) 20 | }) as any; -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 3rd-party-sample-app 2 | 3 | This is a sample application to get you familiarized with working with Descope's 3rd party sample applications 4 | 5 | ## Getting Started 6 | 7 | In order to launch this app: 8 | 9 | 1. Clone the repo 10 | 11 | ``` 12 | git clone git@github.com:descope-sample-apps/3rd-party-sample-app.git 13 | ``` 14 | 15 | 2. Set up Descope environment variables in .env.local file 16 | 17 | Either copy the below, or copy the included `.env.local.example` to `.env.example`. Provide the applicable configurations for your project. 18 | 19 | ``` 20 | AUTH_SECRET="" # Added by `npx auth`. Read more: https://cli.authjs.dev 21 | CLIENT_SECRET="" # The client Secret of the configured 3rd party application within Descope. 22 | CLIENT_ID="" # The client ID of the configured 3rd party application within Descope. 23 | BASE_URL="https://api.descope.com" # The custom CNAME URL of your Descope project. If not configured, leave as is. 24 | PROJECT_ID="" # The project ID of your Descope project. 25 | SCOPES="openid" # The scope of the user. If not configured, leave as is. 26 | CONSENT_SCOPES="" # The consent scope to validate for the user, these would be configured under permission scopes within Descope. If not configured, leave as is. 27 | ``` 28 | 29 | 3. Install dependencies 30 | 31 | ``` bash 32 | npm i 33 | # or 34 | yarn install 35 | ``` 36 | 37 | 4. Start the app 38 | 39 | ```bash 40 | npm run dev 41 | # or 42 | yarn dev 43 | ``` 44 | 45 | ## Utilizing the Application 46 | 47 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the application. 48 | 49 | ### Initiate a 3rd Party Application Consent 50 | 51 | You can initiate a 3rd Party Consent by clicking on the `Connect Descoper Site`. This will navigate to the `Flow Hosting URL` configured within the 3rd Party Application within Descope. 52 | 53 | #### Partner Application Side 54 | 55 | Once you have authenticated, within the Partner Application Pane of the application, you can then refresh the user's tokens, or sign out of the application. The user's `id_token` is parsed to be human readable, and the access_token and refresh_token are displayed 56 | 57 | #### Partner Backend Side 58 | 59 | Within the right-hand side of the application, you can click the `Get App Token` button to fetch the application's `access_token` which can then be used to query for a user's `access_token` to do stuff on their behalf 60 | 61 | ## Learn More 62 | 63 | To learn more about Next.js, take a look at the following resources: 64 | 65 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 66 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 67 | 68 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 69 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { signIn, signOut, useSession } from "next-auth/react"; 3 | import { useState } from "react"; 4 | 5 | export default function Home() { 6 | const [appToken, setAppToken] = useState(null); 7 | const { data: session, status, update } = useSession(); 8 | 9 | const getAppToken = async () => 10 | await fetch("/api/app") 11 | .then((res) => (res.ok && res) || Promise.reject(res)) 12 | .then((res) => res.json()) 13 | .then((res) => 14 | JSON.parse( 15 | Buffer.from(res.access_token.split(".")[1], "base64").toString( 16 | "ascii" 17 | ) 18 | ) 19 | ) 20 | .catch((res) => res.json()); 21 | return ( 22 |
23 |
24 |
Partner App
25 | 31 | {session && session?.user && ( 32 |
33 |             Session:{" "}
34 |             {JSON.stringify(
35 |               {
36 |                 ...session,
37 |                 user: {
38 |                   ...session.user,
39 |                   id_token: session.user.id_token
40 |                     ? JSON.parse(session.user.id_token)
41 |                     : "",
42 |                 },
43 |               },
44 |               null,
45 |               2
46 |             )}
47 |           
48 | )} 49 | {session && ( 50 | <> 51 | 57 | 63 | 64 | )} 65 |
66 |
67 |
Partner Backend
68 | 74 |
75 | {appToken && ( 76 |
77 |             App Token: {JSON.stringify(appToken, null, 2)}
78 |           
79 | )} 80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { DefaultSession } from "next-auth"; 2 | import { OIDCConfig, OIDCUserConfig } from "next-auth/providers"; 3 | import { DescopeProfile } from "next-auth/providers/descope"; 4 | 5 | export const baseUrl = process.env.BASE_URL ?? "https://api.descope.com"; 6 | export const projectId = process.env.PROJECT_ID ?? ""; 7 | export const issuer = baseUrl + "/v1/apps/" + projectId; 8 | export const clientId = process.env.CLIENT_ID ?? ""; 9 | export const clientSecret = process.env.CLIENT_SECRET ?? ""; 10 | export const scope = process.env.SCOPES ?? "openid"; 11 | export const consentScopes = process.env.CONSENT_SCOPES ?? ""; 12 | 13 | const DescopeOAuthApps = ( // based on Descope provider 14 | config: OIDCUserConfig, 15 | ): OIDCConfig => { 16 | return { 17 | id: "customapp", 18 | name: "Custom App", 19 | type: "oidc", 20 | style: { bg: "#1C1C23", text: "#ffffff" }, 21 | checks: ["pkce", "state"], 22 | options: config, 23 | authorization: { 24 | params: { scope, prompt: "consent", access_type: "offline" }, 25 | }, 26 | client: { token_endpoint_auth_method: "client_secret_post" }, // required for backend exchange of app token 27 | profile(profile) { 28 | return { 29 | id: profile.sub, 30 | } 31 | }, 32 | 33 | 34 | } 35 | } 36 | 37 | export const { handlers, signIn, signOut, auth } = NextAuth({ 38 | debug: true, 39 | 40 | providers: [{ 41 | ...DescopeOAuthApps({ issuer }), 42 | clientId, 43 | clientSecret, 44 | }], 45 | callbacks: { 46 | // we use the `jwt` callback to control what goes into the JWT 47 | // https://authjs.dev/guides/refresh-token-rotation#jwt-strategy 48 | // using the database strategy is preferred for production 49 | async jwt({ token, account, session }) { 50 | if (token.error) 51 | delete token.error 52 | if (account) { 53 | // First-time login, save the `access_token`, its expiry and the `refresh_token` 54 | return { 55 | // there's a limit of 4096 bytes for the JWT payload, so we only store the necessary data 56 | // use the database strategy for production.. 57 | ...token, 58 | access_token: account.access_token, 59 | expires_at: account.expires_at, 60 | refresh_token: account.refresh_token, 61 | id_token: account.id_token ? Buffer.from(account.id_token.split(".")[1], "base64").toString() : "", 62 | } 63 | } else if (!session?.refresh && token.expires_at && Date.now() < token.expires_at * 1000) { 64 | // Subsequent logins, but the `access_token` is still valid 65 | return token 66 | } else { 67 | // Subsequent logins, but the `access_token` has expired, try to refresh it 68 | if (!token.refresh_token) throw new TypeError("Missing refresh_token") 69 | 70 | try { 71 | // The `token_endpoint` can be found in the provider's documentation. Or if they support OIDC, 72 | // at their `/.well-known/openid-configuration` endpoint. 73 | const response = await fetch(baseUrl + "/oauth2/v1/apps/token", { 74 | method: "POST", 75 | body: new URLSearchParams({ 76 | client_id: clientId, 77 | client_secret: clientSecret, 78 | grant_type: "refresh_token", 79 | refresh_token: token.refresh_token!, 80 | }), 81 | }) 82 | 83 | const tokensOrError = await response.json() 84 | 85 | if (!response.ok) throw tokensOrError 86 | 87 | const newTokens = tokensOrError as { 88 | access_token: string 89 | expires_in: number 90 | refresh_token?: string 91 | id_token?: string 92 | } 93 | 94 | token.access_token = newTokens.access_token 95 | token.id_token = newTokens.id_token ? Buffer.from(newTokens.id_token.split(".")[1], "base64").toString() : ""; 96 | token.expires_at = Math.floor( 97 | Date.now() / 1000 + newTokens.expires_in 98 | ) 99 | // Some providers only issue refresh tokens once, so preserve if we did not get a new one 100 | if (newTokens.refresh_token) 101 | token.refresh_token = newTokens.refresh_token 102 | 103 | return token 104 | } catch (error) { 105 | console.error("Error refreshing access_token", error) 106 | // If we fail to refresh the token, return an error so we can handle it on the page 107 | token.error = "RefreshTokenError" 108 | return token 109 | } 110 | } 111 | }, 112 | async session({ session, token }) { 113 | session.error = token.error 114 | session.user.id_token = token.id_token 115 | session.user.access_token = token.access_token 116 | session.user.refresh_token = token.refresh_token 117 | session.user.expires_at = token.expires_at 118 | return session 119 | }, 120 | }, 121 | 122 | }); 123 | 124 | 125 | declare module "next-auth" { 126 | interface Session { 127 | error?: "RefreshTokenError" 128 | user: { 129 | id_token?: string 130 | access_token?: string 131 | expires_at?: number 132 | refresh_token?: string 133 | } & DefaultSession["user"] 134 | } 135 | } 136 | 137 | declare module "@auth/core/jwt" { 138 | interface JWT extends DefaultJWT { 139 | id_token?: string 140 | access_token?: string 141 | expires_at?: number 142 | refresh_token?: string 143 | error?: "RefreshTokenError" 144 | } 145 | } --------------------------------------------------------------------------------