├── .prettierignore ├── utils ├── index.ts └── certificate.ts ├── Procfile ├── postcss.config.js ├── public ├── favicon.ico └── mock-saml-preview-image.png ├── pages ├── namespace │ └── [namespace] │ │ ├── saml │ │ └── login.tsx │ │ └── index.tsx ├── api │ ├── namespace │ │ └── [namespace] │ │ │ └── saml │ │ │ ├── sso.ts │ │ │ ├── auth.ts │ │ │ └── metadata.ts │ ├── health.ts │ └── saml │ │ ├── metadata.ts │ │ ├── auth.ts │ │ └── sso.ts ├── _app.tsx ├── index.tsx └── saml │ └── login.tsx ├── .dockerignore ├── tailwind.config.js ├── next-env.d.ts ├── next.config.js ├── lib ├── entity-id.ts └── env.ts ├── .prettierrc.js ├── .env.example ├── .gitignore ├── tsconfig.json ├── types └── index.ts ├── .github ├── dependabot.yml └── workflows │ └── docker-publish.yml ├── app.json ├── styles └── globals.css ├── Dockerfile ├── components ├── Footer.tsx ├── Layout.tsx └── Header.tsx ├── package.json ├── .release-it.json ├── eslint.config.cjs ├── README.md └── LICENSE /.prettierignore: -------------------------------------------------------------------------------- 1 | eslint.config.cjs -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './certificate'; 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./node_modules/.bin/next start -p $PORT 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['@tailwindcss/postcss'], 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ory/mocksaml/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /pages/namespace/[namespace]/saml/login.tsx: -------------------------------------------------------------------------------- 1 | import Login from '../../../saml/login'; 2 | 3 | export default Login; 4 | -------------------------------------------------------------------------------- /public/mock-saml-preview-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ory/mocksaml/HEAD/public/mock-saml-preview-image.png -------------------------------------------------------------------------------- /pages/api/namespace/[namespace]/saml/sso.ts: -------------------------------------------------------------------------------- 1 | import handler from 'pages/api/saml/sso'; 2 | 3 | export default handler; 4 | -------------------------------------------------------------------------------- /pages/api/namespace/[namespace]/saml/auth.ts: -------------------------------------------------------------------------------- 1 | import handler from 'pages/api/saml/auth'; 2 | 3 | export default handler; 4 | -------------------------------------------------------------------------------- /pages/api/namespace/[namespace]/saml/metadata.ts: -------------------------------------------------------------------------------- 1 | import handler from 'pages/api/saml/metadata'; 2 | 3 | export default handler; 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | .github 9 | .vscode 10 | .env 11 | .env.example -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 4 | plugins: [], 5 | }; 6 | -------------------------------------------------------------------------------- /pages/namespace/[namespace]/index.tsx: -------------------------------------------------------------------------------- 1 | import Home, { getServerSideProps as _getServerSideProps } from '../../index'; 2 | 3 | export const getServerSideProps = _getServerSideProps; 4 | 5 | export default Home; 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./.next/types/routes.d.ts"; 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | output: 'standalone', 5 | webpack: (config, { isServer }) => { 6 | if (!isServer) { 7 | config.resolve.fallback = { 8 | fs: false, 9 | zlib: false, 10 | }; 11 | } 12 | 13 | return config; 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /lib/entity-id.ts: -------------------------------------------------------------------------------- 1 | const getEntityId = (entityId: string, namespace: string | undefined) => { 2 | return namespace ? `${entityId}/${namespace}` : entityId; 3 | }; 4 | 5 | const getSSOUrl = (appUrl: string, namespace: string | undefined) => { 6 | return `${appUrl}/api` + (namespace ? `/namespace/${namespace}` : '') + '/saml/sso'; 7 | }; 8 | 9 | export { getEntityId, getSSOUrl }; 10 | -------------------------------------------------------------------------------- /utils/certificate.ts: -------------------------------------------------------------------------------- 1 | const fetchPublicKey = (): string => { 2 | return process.env.PUBLIC_KEY ? Buffer.from(process.env.PUBLIC_KEY!, 'base64').toString('ascii') : ''; 3 | }; 4 | 5 | const fetchPrivateKey = (): string => { 6 | return process.env.PRIVATE_KEY ? Buffer.from(process.env.PRIVATE_KEY!, 'base64').toString('ascii') : ''; 7 | }; 8 | 9 | export { fetchPublicKey, fetchPrivateKey }; 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | bracketSameLine: true, 4 | singleQuote: true, 5 | jsxSingleQuote: true, 6 | trailingComma: 'es5', 7 | semi: true, 8 | printWidth: 110, 9 | arrowParens: 'always', 10 | overrides: [ 11 | { 12 | files: ['tsconfig.json', 'jsconfig.json'], 13 | options: { 14 | parser: 'jsonc', 15 | }, 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /pages/api/health.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | import packageInfo from '../../package.json'; 4 | 5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 6 | try { 7 | if (req.method !== 'GET') { 8 | throw new Error('Method not allowed'); 9 | } 10 | 11 | res.status(200).json({ 12 | version: packageInfo.version, 13 | }); 14 | } catch (err: any) { 15 | const { statusCode = 503 } = err; 16 | res.status(statusCode).json({}); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/env.ts: -------------------------------------------------------------------------------- 1 | import { fetchPrivateKey, fetchPublicKey } from 'utils'; 2 | 3 | const appUrl = 4 | process.env.APP_URL || 5 | `https://${process.env.VERCEL_BRANCH_URL}` || 6 | `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` || 7 | 'http://localhost:4000'; 8 | const entityId = process.env.ENTITY_ID || 'https://saml.example.com/entityid'; 9 | const privateKey = fetchPrivateKey(); 10 | const publicKey = fetchPublicKey(); 11 | 12 | const config = { 13 | appUrl, 14 | entityId, 15 | privateKey, 16 | publicKey, 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_URL=http://localhost:4000 2 | ENTITY_ID=https://saml.example.com/entityid 3 | 4 | # Generate a private/public key combination, this is needed for signing the SAML AuthnRequest 5 | # openssl req -x509 -newkey rsa:2048 -keyout key.pem -out public.crt -sha256 -days 365000 -nodes 6 | # Base64 encoded value of public key `cat public.crt | base64` 7 | PUBLIC_KEY= 8 | # Base64 encoded value of private key `cat key.pem | base64` 9 | PRIVATE_KEY= 10 | 11 | # Google Tag Manager ID. Leave as blank because removing this causes next.js build to fail. 12 | NEXT_PUBLIC_GTM_ID="" 13 | -------------------------------------------------------------------------------- /.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 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | .env 39 | key.pem 40 | public.crt 41 | .vscode 42 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Layout from 'components/Layout'; 2 | import type { AppProps } from 'next/app'; 3 | import { useEffect } from 'react'; 4 | import TagManager from 'react-gtm-module'; 5 | import 'styles/globals.css'; 6 | 7 | function MyApp({ Component, pageProps }: AppProps) { 8 | useEffect(() => { 9 | if (process.env.NEXT_PUBLIC_GTM_ID && process.env.NEXT_PUBLIC_GTM_ID.length > 0) { 10 | TagManager.initialize({ gtmId: process.env.NEXT_PUBLIC_GTM_ID }); 11 | } 12 | }, []); 13 | 14 | return ( 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default MyApp; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "react-jsx", 20 | "incremental": true, 21 | "baseUrl": "." 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type ServiceProvider = { 2 | acs_url: string; 3 | entity_id: string; 4 | }; 5 | 6 | export type IdentityProvider = { 7 | ssoUrl: string; 8 | entityId: string; 9 | }; 10 | 11 | export type App = { 12 | id: string; 13 | name: string; 14 | description?: string | null; 15 | certificate?: string | null; 16 | } & ServiceProvider; 17 | 18 | export type IdPMetadata = { 19 | certificate: string; 20 | fingerprint?: string; 21 | } & IdentityProvider; 22 | 23 | export type SAMLRequest = { 24 | entityID: string; 25 | callbackUrl: string; 26 | signingKey: string; 27 | }; 28 | 29 | export type AuthNRequest = { 30 | relayState: string; 31 | samlRequest: string; 32 | }; 33 | 34 | export type User = { 35 | id: string; 36 | email: string; 37 | firstName: string; 38 | lastName: string; 39 | }; 40 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: '/' # Location of package manifests 10 | schedule: 11 | interval: 'daily' 12 | groups: 13 | next: 14 | patterns: 15 | - 'next' 16 | - 'eslint-config-next' 17 | react: 18 | patterns: 19 | - 'react' 20 | - 'react-dom' 21 | - package-ecosystem: 'docker' 22 | directory: '/' 23 | schedule: 24 | interval: 'daily' 25 | - package-ecosystem: 'github-actions' 26 | directory: '/' 27 | schedule: 28 | interval: 'weekly' 29 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mock SAML", 3 | "description": "Mock SAML is a free SAML 2.0 Identity Provider for testing SAML SSO integrations.", 4 | "repository": "https://github.com/boxyhq/mock-saml", 5 | "logo": "https://boxyhq.com/img/logo.png", 6 | "keywords": ["saml", "saml2", "saml-idp", "mock-saml-idp", "test-saml", "saml-testing", "fake-saml-idp"], 7 | "env": { 8 | "APP_URL": { 9 | "description": "Public root URL of the Mock SAML installation. Replace with 'App name' from above.", 10 | "value": "https://.herokuapp.com" 11 | }, 12 | "ENTITY_ID": { 13 | "description": "Identity Provider entity ID.", 14 | "value": "https://mocksaml.com/entityid" 15 | }, 16 | "PUBLIC_KEY": { 17 | "description": "Base64 encoded value of public key." 18 | }, 19 | "PRIVATE_KEY": { 20 | "description": "Base64 encoded value of private key." 21 | }, 22 | "NEXT_PUBLIC_GTM_ID": { 23 | "description": "Google Tag Manager ID.", 24 | "required": false 25 | } 26 | }, 27 | "success_url": "/" 28 | } 29 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | /* globals.css */ 2 | @import 'tailwindcss'; 3 | 4 | /* Tailwind base, components, utilities */ 5 | @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; 8 | 9 | @theme { 10 | /* Brand */ 11 | --color-primary: oklch(51.1% 0.262 276.966); 12 | --color-primary-hover: oklch(47% 0.262 276.966); 13 | 14 | /* Soft / UI surfaces */ 15 | --color-primary-soft: oklch(51.1% 0.262 276.966 / 0.1); 16 | --color-primary-muted: oklch(51.1% 0.262 276.966 / 0.6); 17 | 18 | /* Focus ring */ 19 | --color-primary-ring: oklch(51.1% 0.262 276.966 / 0.4); 20 | } 21 | 22 | /* Optional body defaults */ 23 | body { 24 | background-color: white; 25 | font-family: ui-sans-serif, system-ui, sans-serif; 26 | color: #1f2937; /* Tailwind gray-900 default */ 27 | line-height: 1.5; 28 | margin: 0; 29 | padding: 0; 30 | } 31 | 32 | /* Optional focus outline for accessibility */ 33 | :focus { 34 | outline: 2px solid transparent; 35 | outline-offset: 2px; 36 | ring-offset-width: 2px; 37 | } 38 | 39 | /* You can define custom colors directly in tailwind.config.js instead of here */ 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODEJS_IMAGE=node:22.16-alpine3.21 2 | FROM --platform=$BUILDPLATFORM $NODEJS_IMAGE AS base 3 | 4 | FROM base AS deps 5 | RUN apk add --no-cache libc6-compat 6 | WORKDIR /app 7 | 8 | # Install dependencies based on the preferred package manager 9 | COPY package.json package-lock.json ./ 10 | RUN npm ci 11 | 12 | FROM base AS builder 13 | WORKDIR /app 14 | COPY --from=deps /app/node_modules ./node_modules 15 | COPY . . 16 | 17 | ENV NEXT_PUBLIC_GTM_ID="" 18 | ENV NEXT_TELEMETRY_DISABLED=1 19 | 20 | RUN npm run build 21 | 22 | FROM $NODEJS_IMAGE AS runner 23 | WORKDIR /app 24 | 25 | ENV NODE_OPTIONS="--max-http-header-size=81920" 26 | ENV NODE_ENV=production 27 | ENV NEXT_TELEMETRY_DISABLED=1 28 | 29 | 30 | RUN addgroup --system --gid 1001 nodejs 31 | RUN adduser --system --uid 1001 nextjs 32 | 33 | COPY --from=builder /app/public ./public 34 | 35 | # Automatically leverage output traces to reduce image size 36 | # https://nextjs.org/docs/advanced-features/output-file-tracing 37 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 38 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 39 | 40 | USER nextjs 41 | 42 | EXPOSE 4000 43 | 44 | ENV PORT=4000 45 | 46 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | export default function Footer() { 2 | return ( 3 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mock-saml", 3 | "version": "1.4.2", 4 | "description": "Mock SAML is a free SAML 2.0 Identity Provider for testing SAML SSO integrations.", 5 | "private": true, 6 | "license": "Apache 2.0", 7 | "scripts": { 8 | "dev": "next dev --webpack -p 4000", 9 | "build": "next build --webpack", 10 | "start": "next start -p 4000", 11 | "lint": "eslint -c eslint.config.cjs ./", 12 | "check-format": "prettier --check .", 13 | "format": "prettier --write .", 14 | "release": "git checkout release && git merge origin/main && release-it && git checkout main && git merge origin/release && git push origin main" 15 | }, 16 | "dependencies": { 17 | "@boxyhq/saml20": "1.12.2", 18 | "next": "16.0.10", 19 | "react": "19.2.3", 20 | "react-dom": "19.2.3", 21 | "react-gtm-module": "2.0.11" 22 | }, 23 | "devDependencies": { 24 | "@tailwindcss/postcss": "4.1.18", 25 | "@types/node": "25.0.2", 26 | "@types/react": "19.2.7", 27 | "@types/react-gtm-module": "2.0.4", 28 | "@typescript-eslint/parser": "8.50.0", 29 | "eslint": "9.39.2", 30 | "eslint-config-next": "16.0.10", 31 | "eslint-config-prettier": "10.1.8", 32 | "eslint-plugin-i18next": "6.1.3", 33 | "json": "11.0.0", 34 | "postcss": "8.5.6", 35 | "prettier": "3.7.4", 36 | "prettier-plugin-tailwindcss": "0.7.2", 37 | "release-it": "19.1.0", 38 | "tailwindcss": "4.1.18", 39 | "typescript": "5.9.3" 40 | }, 41 | "engines": { 42 | "node": ">=18.14.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": {}, 3 | "git": { 4 | "changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}", 5 | "requireCleanWorkingDir": true, 6 | "requireBranch": "release", 7 | "requireUpstream": true, 8 | "requireCommits": false, 9 | "requireCommitsFail": true, 10 | "commitsPath": "", 11 | "addUntrackedFiles": false, 12 | "commit": true, 13 | "commitMessage": "Release ${version}", 14 | "commitArgs": [], 15 | "tag": true, 16 | "tagExclude": null, 17 | "tagName": null, 18 | "tagMatch": null, 19 | "getLatestTagFromAllRefs": false, 20 | "tagAnnotation": "Release ${version}", 21 | "tagArgs": [], 22 | "push": true, 23 | "pushArgs": ["--follow-tags"], 24 | "pushRepo": "" 25 | }, 26 | "npm": { 27 | "publish": false 28 | }, 29 | "github": { 30 | "release": true, 31 | "releaseName": "Release v${version}", 32 | "releaseNotes": null, 33 | "autoGenerate": false, 34 | "preRelease": false, 35 | "draft": true, 36 | "tokenRef": "GITHUB_TOKEN", 37 | "assets": null, 38 | "host": null, 39 | "timeout": 0, 40 | "proxy": null, 41 | "skipChecks": false, 42 | "web": false, 43 | "comments": { 44 | "submit": false, 45 | "issue": ":rocket: _This issue has been resolved in v${version}. See [${releaseName}](${releaseUrl}) for release notes._", 46 | "pr": ":rocket: _This pull request is included in v${version}. See [${releaseName}](${releaseUrl}) for release notes._" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pages/api/saml/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import saml from '@boxyhq/saml20'; 3 | 4 | import config from 'lib/env'; 5 | import type { IdPMetadata } from 'types'; 6 | import stream from 'stream'; 7 | import { promisify } from 'util'; 8 | import { getEntityId, getSSOUrl } from 'lib/entity-id'; 9 | 10 | const pipeline = promisify(stream.pipeline); 11 | 12 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 13 | switch (req.method) { 14 | case 'GET': 15 | return await MetadataUrl(); 16 | default: 17 | return res.status(405).end(`Method ${req.method} Not Allowed`); 18 | } 19 | 20 | // Metadata URL 21 | async function MetadataUrl() { 22 | const { download } = req.query as { download: any }; 23 | 24 | const filename = 'mock-saml-metadata' + (req.query.namespace ? `-${req.query.namespace}` : '') + '.xml'; 25 | 26 | const xml = saml.createIdPMetadataXML({ 27 | entityId: getEntityId(config.entityId, req.query.namespace as any), 28 | ssoUrl: getSSOUrl(config.appUrl, req.query.namespace as any), 29 | x509cert: saml.stripCertHeaderAndFooter(config.publicKey), 30 | wantAuthnRequestsSigned: true, 31 | }); 32 | 33 | res.setHeader('Content-type', 'text/xml'); 34 | 35 | if (download || download === '') { 36 | res.setHeader('Content-Disposition', `attachment; filename=${filename}`); 37 | 38 | await pipeline(xml, res); 39 | return; 40 | } 41 | 42 | res.send(xml); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import type { ReactNode } from 'react'; 3 | import Header from './Header'; 4 | 5 | type LayoutProps = { 6 | children: ReactNode; 7 | }; 8 | 9 | export default function Layout({ children }: LayoutProps) { 10 | const title = 'Mock SAML - A free SAML 2.0 Identity Provider for testing SAML SSO integrations.'; 11 | const description = 'A free SAML 2.0 Identity Provider for testing SAML SSO integrations.'; 12 | const website = 'https://mocksaml.com'; 13 | const ogImage = `${website}/mock-saml-preview-image.png`; 14 | 15 | return ( 16 | <> 17 | 18 | 19 | 20 | {title} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
{children}
39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /pages/api/saml/auth.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto'; 2 | import config from 'lib/env'; 3 | import type { NextApiRequest, NextApiResponse } from 'next'; 4 | import type { User } from 'types'; 5 | import saml from '@boxyhq/saml20'; 6 | import { getEntityId } from 'lib/entity-id'; 7 | 8 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 9 | if (req.method === 'POST') { 10 | const { email, audience, acsUrl, id, relayState } = req.body; 11 | 12 | if (!email.endsWith('@example.com') && !email.endsWith('@example.org')) { 13 | res.status(403).send(`${email} denied access`); 14 | } 15 | 16 | const userId = createHash('sha256').update(email).digest('hex'); 17 | const userName = email.split('@')[0]; 18 | 19 | const user: User = { 20 | id: userId, 21 | email, 22 | firstName: userName, 23 | lastName: userName, 24 | }; 25 | 26 | const xmlSigned = await saml.createSAMLResponse({ 27 | issuer: getEntityId(config.entityId, req.query.namespace as any), 28 | audience, 29 | acsUrl, 30 | requestId: id, 31 | claims: { 32 | email: user.email, 33 | raw: user, 34 | }, 35 | privateKey: config.privateKey, 36 | publicKey: config.publicKey, 37 | }); 38 | 39 | const encodedSamlResponse = Buffer.from(xmlSigned).toString('base64'); 40 | const html = saml.createPostForm(acsUrl, [ 41 | { 42 | name: 'RelayState', 43 | value: relayState, 44 | }, 45 | { 46 | name: 'SAMLResponse', 47 | value: encodedSamlResponse, 48 | }, 49 | ]); 50 | 51 | res.send(html); 52 | } else { 53 | res.status(405).send(`Method ${req.method} Not Allowed`); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/api/saml/sso.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import saml from '@boxyhq/saml20'; 3 | 4 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 5 | switch (req.method) { 6 | case 'GET': 7 | return await processSAMLRequest(req, res, false); 8 | case 'POST': 9 | return await processSAMLRequest(req, res, true); 10 | default: 11 | return res.status(405).end(`Method ${req.method} Not Allowed`); 12 | } 13 | } 14 | 15 | async function processSAMLRequest(req: NextApiRequest, res: NextApiResponse, isPost: boolean) { 16 | let samlRequest, relayState, isDeflated; 17 | 18 | if (isPost) { 19 | relayState = req.body.RelayState; 20 | samlRequest = req.body.SAMLRequest; 21 | isDeflated = false; 22 | } else { 23 | relayState = req.query.RelayState; 24 | samlRequest = req.query.SAMLRequest; 25 | // sigAlg = req.query.SigAlg; 26 | // signature = req.query.Signature; 27 | 28 | isDeflated = true; 29 | } 30 | 31 | try { 32 | const rawRequest = await saml.decodeBase64(samlRequest, isDeflated); 33 | 34 | const { id, audience, acsUrl, providerName, publicKey } = await saml.parseSAMLRequest(rawRequest, isPost); 35 | 36 | if (isPost) { 37 | if (!saml.validateSignature(rawRequest, publicKey, null)) { 38 | throw new Error('Invalid signature'); 39 | } 40 | } 41 | 42 | const params = new URLSearchParams({ id, audience, acsUrl, providerName, relayState }); 43 | 44 | const loginUrl = (req.query.namespace ? `/namespace/${req.query.namespace}` : '') + '/saml/login'; 45 | 46 | res.redirect(302, `${loginUrl}?${params.toString()}`); 47 | } catch (err) { 48 | console.error(err); 49 | 50 | res.status(500).send(`${err}`); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export default function Header() { 4 | return ( 5 |
6 |
7 | 11 | 20 | 21 | 22 | Mock SAML 23 | 24 |
25 | 26 | 31 | Integrate SAML with a few lines of code 32 | 33 | 34 | 35 | Made with {' '} 36 | 37 | Ory 38 | 39 | 40 |
41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /eslint.config.cjs: -------------------------------------------------------------------------------- 1 | const nextCoreWebVitals = require('eslint-config-next/core-web-vitals'); 2 | const i18Next = require('eslint-plugin-i18next'); 3 | const globals = require('globals'); 4 | const tsParser = require('@typescript-eslint/parser'); 5 | const js = require('@eslint/js'); 6 | 7 | const { FlatCompat } = require('@eslint/eslintrc'); 8 | 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all, 13 | }); 14 | 15 | module.exports = [ 16 | { 17 | ignores: [ 18 | '.next', 19 | '**/node_modules', 20 | '**/dist', 21 | 'eslint.config.cjs', 22 | 'node_modules/**', 23 | '.next/**', 24 | 'out/**', 25 | 'build/**', 26 | 'next-env.d.ts', 27 | ], 28 | }, 29 | ...compat.extends('eslint:recommended'), 30 | ...compat.extends('prettier'), 31 | ...nextCoreWebVitals, 32 | ...compat.extends('plugin:i18next/recommended'), 33 | { 34 | plugins: { 35 | i18next: i18Next, 36 | }, 37 | 38 | languageOptions: { 39 | globals: { 40 | ...globals.node, 41 | }, 42 | 43 | parser: tsParser, 44 | ecmaVersion: 13, 45 | sourceType: 'module', 46 | }, 47 | }, 48 | { 49 | files: ['**/*.ts', '**/*.tsx'], 50 | 51 | rules: { 52 | 'no-undef': 'off', 53 | 'no-unused-vars': 'off', 54 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 55 | '@typescript-eslint/no-explicit-any': 'off', 56 | 'import/no-anonymous-default-export': 'off', 57 | '@typescript-eslint/no-require-imports': 'off', 58 | 'i18next/no-literal-string': 'off', 59 | }, 60 | }, 61 | { 62 | files: ['**/*.js'], 63 | 64 | rules: { 65 | '@typescript-eslint/no-var-requires': 'off', 66 | '@typescript-eslint/no-require-imports': 'off', 67 | }, 68 | }, 69 | { 70 | files: ['sdk/**/*'], 71 | 72 | rules: { 73 | '@next/next/no-img-element': 'off', 74 | }, 75 | }, 76 | ]; 77 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release 8 | tags: 9 | - 'beta-v*' 10 | pull_request: 11 | # Trigger only for PRs that target main branch 12 | branches: 13 | - main 14 | 15 | # Allows you to run this workflow manually from the Actions tab 16 | workflow_dispatch: 17 | 18 | jobs: 19 | build-and-push-docker-image: 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Check Out Repo 24 | uses: actions/checkout@v6 25 | 26 | - run: | 27 | npm install 28 | 29 | IMAGE_PATH=boxyhq/mock-saml 30 | echo "IMAGE_PATH=${IMAGE_PATH}" >> $GITHUB_OUTPUT 31 | MOCK_SAML_VERSION=$(echo $(cat ./package.json) | ./node_modules/json/lib/json.js version) 32 | echo "NPMVERSION=$(echo ${MOCK_SAML_VERSION})" >> $GITHUB_OUTPUT 33 | id: npmversion 34 | 35 | - name: Get short SHA 36 | id: slug 37 | run: echo "SHA7=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT 38 | 39 | - run: echo ${{ steps.slug.outputs.SHA7 }} 40 | - run: echo ${{ steps.npmversion.outputs.NPMVERSION }} 41 | 42 | - name: Set up QEMU 43 | uses: docker/setup-qemu-action@v3 44 | 45 | - name: Set up Docker Buildx 46 | id: buildx 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Login to Docker Hub 50 | if: github.ref == 'refs/heads/release' 51 | uses: docker/login-action@v3 52 | with: 53 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 54 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 55 | 56 | - name: Build and push 57 | id: docker_build 58 | uses: docker/build-push-action@v6 59 | with: 60 | context: ./ 61 | file: ./Dockerfile 62 | platforms: linux/amd64,linux/arm64 63 | push: ${{ github.ref == 'refs/heads/release' }} 64 | tags: ${{ steps.npmversion.outputs.IMAGE_PATH }}:latest,${{ steps.npmversion.outputs.IMAGE_PATH }}:${{ steps.slug.outputs.SHA7 }},${{ steps.npmversion.outputs.IMAGE_PATH }}:${{ steps.npmversion.outputs.NPMVERSION }} 65 | 66 | - name: Image digest 67 | run: echo ${{ steps.docker_build.outputs.digest }} 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mock SAML from BoxyHQ 2 | 3 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 4 | [![Deploy with Vercel](https://vercel.com/button)]() 5 | 6 | Mock SAML is a free SAML 2.0 Identity Provider for testing SAML SSO integrations. 7 | 8 | ## Namespaces 9 | 10 | Try [Mock SAML](https://mocksaml.com/), our free hosted service. Whilst we use the root domain for our own testing you can create your own unique namespace by navigating to [https://mocksaml.com/namespace/any_name_of_your_choice](https://mocksaml.com/namespace/any_name_of_your_choice). 11 | 12 | ## Install 13 | 14 | ### With Docker 15 | 16 | The docker container can be found at [boxyhq/mock-saml](https://hub.docker.com/r/boxyhq/mock-saml). 17 | 18 | ```bash 19 | docker run \ 20 | -p 4000:4000 \ 21 | -e APP_URL="http://localhost:4000" \ 22 | -e ENTITY_ID="https://saml.example.com/entityid" \ 23 | -e PUBLIC_KEY="" \ 24 | -e PRIVATE_KEY="" \ 25 | -d boxyhq/mock-saml 26 | ``` 27 | 28 | Refer to [env.example](https://github.com/boxyhq/mock-saml/blob/main/.env.example#L5C3-L5C97) for instructions on how to create the key pair. 29 | Replace `` with Base64 encoded value of public key. 30 | Replace `` with Base64 encoded value of private key. 31 | 32 | ### Without Docker 33 | 34 | ``` 35 | git clone https://github.com/boxyhq/mock-saml.git 36 | ``` 37 | 38 | ``` 39 | cd mock-saml 40 | ``` 41 | 42 | Install dependencies 43 | 44 | ``` 45 | npm install 46 | ``` 47 | 48 | Update `.env` with your own keys. 49 | 50 | ``` 51 | cp .env.example .env 52 | ``` 53 | 54 | Build the Next.js app. 55 | 56 | ``` 57 | npm run build 58 | ``` 59 | 60 | Run the Mock SAML server. 61 | 62 | ``` 63 | npm run start 64 | ``` 65 | 66 | ## Contributing 67 | 68 | Thanks for taking the time to contribute! Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are greatly appreciated. 69 | 70 | Please try to create bug reports that are: 71 | 72 | - _Reproducible._ Include steps to reproduce the problem. 73 | - _Specific._ Include as much detail as possible: which version, what environment, etc. 74 | - _Unique._ Do not duplicate existing opened issues. 75 | - _Scoped to a Single Bug._ One bug per report. 76 | 77 | ## Community 78 | 79 | - [Discord](https://discord.gg/uyb7pYt4Pa) (For live discussion with the Open-Source Community and BoxyHQ team) 80 | - [Twitter](https://twitter.com/BoxyHQ) (Follow us) 81 | - [GitHub Issues](https://https://github.com/boxyhq/mock-saml/issues) (Contributions, report issues and product ideas) 82 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import config from '../lib/env'; 5 | import { IdPMetadata } from '../types'; 6 | import { getEntityId, getSSOUrl } from 'lib/entity-id'; 7 | 8 | const Home: React.FC<{ metadata: IdPMetadata; params: any }> = ({ metadata, params }) => { 9 | const namespace = params.namespace; 10 | const { ssoUrl: appUrl, entityId, certificate } = metadata; 11 | const namespaceEntityId = getEntityId(entityId, namespace); 12 | 13 | const metadataDownloadUrl = 14 | '/api' + (namespace ? `/namespace/${namespace}` : '') + '/saml/metadata?download=true'; 15 | const metadataUrl = '/api' + (namespace ? `/namespace/${namespace}` : '') + '/saml/metadata'; 16 | const loginUrl = (namespace ? `/namespace/${namespace}` : '') + '/saml/login'; 17 | const ssoUrl = getSSOUrl(appUrl, namespace); 18 | 19 | return ( 20 |
21 |
22 |

23 | A free SAML 2.0 Identity Provider for testing SAML SSO integrations. 24 |

25 | 26 | {/* Actions */} 27 |
28 |
29 | 35 | Download Metadata 36 | 37 | 38 | 45 | Metadata URL 46 | 47 |
48 | 49 | 55 | Test IdP Login 56 | 57 |
58 | 59 | {/* Metadata Card */} 60 |
61 |

Mock SAML Metadata

62 |
63 |
64 | 65 | 71 |
72 | 73 |
74 | 75 | 81 |
82 | 83 |
84 | 85 |