-
Notifications
You must be signed in to change notification settings - Fork 514
feat: SAML SSO client SDK, dashboard, demo, and e2e tests #1397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
ce66a19
feat(sdk): add signInWithSaml, signInWithSso, getSamlConnectionForEmail
BilalG1 958407d
feat(examples/demo): add SAML SSO demo page
BilalG1 f8093a3
test(e2e): add SAML discover, metadata, and login route tests
BilalG1 36d00f2
test(e2e): add full SAML SP-initiated round-trip tests via mock IdP
BilalG1 edb13f3
feat(dashboard): add SAML SSO management page
BilalG1 5fa9629
fix(saml): use whole-entry config writes; correct test request shapes
BilalG1 fe8197c
fix(dashboard): drop yup.url() validator and use env-only pushable=false
BilalG1 82424d1
feat(saml): gate SAML SSO behind alpha-stage saml-sso app
BilalG1 9cf7e8f
fix(saml): use backend origin for SP base URL in login + ACS routes
BilalG1 8facb27
fix(saml): admin POST /saml-connections must write whole entry
BilalG1 b00fc53
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 a007318
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 e3e09f5
fix(saml): expose SAML SDK methods on StackClientApp interface
BilalG1 314fa17
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 54c4736
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 457cba6
ci(saml): start mock-saml-idp in local-emulator and custom-port e2e w…
BilalG1 e37e5f3
Merge branch 'pr/saml-backend' into pr/saml-client
BilalG1 be4560b
fix(saml): address PR review — absolute SP URLs, domain normalization…
BilalG1 fdfc400
fix(saml): reject signInWithSaml when redirectMethod is "none"
BilalG1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
233 changes: 233 additions & 0 deletions
233
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sso/page-client.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| "use client"; | ||
|
|
||
| import { SmartFormDialog } from "@/components/form-dialog"; | ||
| import { ActionDialog, Alert, Button, Card, CardContent, CardHeader, Typography } from "@/components/ui"; | ||
| import { useUpdateConfig } from "@/lib/config-update"; | ||
| import { getPublicEnvVar } from "@/lib/env"; | ||
| import React, { useState } from "react"; | ||
| import * as yup from "yup"; | ||
| import { AppEnabledGuard } from "../app-enabled-guard"; | ||
| import { PageLayout } from "../page-layout"; | ||
| import { useAdminApp } from "../use-admin-app"; | ||
|
|
||
| function getBrowserApiBase(): string { | ||
| const url = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? ""; | ||
| return url.replace(/\/+$/, ""); | ||
| } | ||
|
|
||
| /** | ||
| * Dashboard for managing SAML SSO connections on the current project. | ||
| * | ||
| * Connection config is stored at tenancy.config.auth.saml.connections — | ||
| * the same JSON-config the seed script and admin /saml-connections | ||
| * endpoints write. This page reads via project.useConfig() and writes | ||
| * via useUpdateConfig() with key paths. | ||
| * | ||
| * V1 scope: single-page list with create + delete dialogs. The | ||
| * paste-IdP-metadata helper that auto-fills idpEntityId/idpSsoUrl/ | ||
| * idpCertificate from a single XML blob is a planned follow-up — for | ||
| * now connection fields are entered manually. | ||
| */ | ||
| export default function PageClient() { | ||
| return ( | ||
| <AppEnabledGuard appId="saml-sso"> | ||
| <PageContent /> | ||
| </AppEnabledGuard> | ||
| ); | ||
| } | ||
|
|
||
| function PageContent() { | ||
| const stackAdminApp = useAdminApp(); | ||
| const project = stackAdminApp.useProject(); | ||
| const config = project.useConfig(); | ||
| const connections = config.auth.saml.connections; | ||
|
|
||
| const [createOpen, setCreateOpen] = useState(false); | ||
| const [deleteId, setDeleteId] = useState<string | null>(null); | ||
|
|
||
| const connectionEntries = Object.entries(connections); | ||
| const apiBase = getBrowserApiBase(); | ||
|
|
||
| return ( | ||
| <PageLayout | ||
| title="SAML SSO" | ||
| description="Manage SAML 2.0 connections so corporate IdP users can sign in to your project." | ||
| actions={<Button onClick={() => setCreateOpen(true)}>Add SAML connection</Button>} | ||
| > | ||
| {connectionEntries.length === 0 && ( | ||
| <Alert> | ||
| No SAML connections configured yet. Click <strong>Add SAML connection</strong> to wire | ||
| up your first IdP. | ||
| </Alert> | ||
| )} | ||
|
|
||
| <div className="grid gap-4"> | ||
| {connectionEntries.map(([id, conn]) => ( | ||
| <Card key={id}> | ||
| <CardHeader> | ||
| <div className="flex items-center justify-between"> | ||
| <div> | ||
| <Typography type="h4">{conn.displayName}</Typography> | ||
| <Typography type="p" variant="secondary" className="text-sm"> | ||
| Connection ID: <code>{id}</code> | ||
| {conn.domain && ( | ||
| <> | ||
| {" "}· Email domain: <code>{conn.domain}</code> | ||
| </> | ||
| )} | ||
| </Typography> | ||
| </div> | ||
| <div className="flex gap-2"> | ||
| <Button variant="destructive" size="sm" onClick={() => setDeleteId(id)}> | ||
| Delete | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <div className="grid gap-2 text-sm"> | ||
| <DetailRow label="IdP Entity ID" value={conn.idpEntityId} /> | ||
| <DetailRow label="IdP SSO URL" value={conn.idpSsoUrl} /> | ||
| <DetailRow | ||
| label="IdP signing cert" | ||
| value={conn.idpCertificate ? `<${conn.idpCertificate.length}-char certificate>` : null} | ||
| /> | ||
| <DetailRow label="Sign-in enabled" value={conn.allowSignIn ? "yes" : "no"} /> | ||
| </div> | ||
| <div className="mt-3 grid gap-1 text-sm border-t pt-3"> | ||
| <Typography type="p" variant="secondary" className="text-xs"> | ||
| Paste these into your IdP's admin console: | ||
| </Typography> | ||
| <DetailRow | ||
| label="SP metadata URL" | ||
| value={`${apiBase}/api/v1/auth/saml/metadata/${id}?project_id=${project.id}`} | ||
| mono | ||
| /> | ||
| <DetailRow | ||
| label="ACS URL" | ||
| value={`${apiBase}/api/v1/auth/saml/acs/${id}`} | ||
| mono | ||
| /> | ||
| </div> | ||
| </CardContent> | ||
| </Card> | ||
| ))} | ||
| </div> | ||
|
|
||
| <CreateDialog open={createOpen} onOpenChange={setCreateOpen} /> | ||
| <DeleteDialog | ||
| connectionId={deleteId} | ||
| onClose={() => setDeleteId(null)} | ||
| // Guard against stale deleteId after a config refresh removed the | ||
| // entry — index types claim non-undefined but the key may be gone. | ||
| displayName={deleteId && Object.hasOwn(connections, deleteId) ? connections[deleteId].displayName : null} | ||
| /> | ||
| </PageLayout> | ||
| ); | ||
| } | ||
|
|
||
| function DetailRow({ label, value, mono }: { label: string, value: string | null | undefined, mono?: boolean }) { | ||
| return ( | ||
| <div className="flex gap-2"> | ||
| <span className="font-medium min-w-[140px]">{label}:</span> | ||
| <span className={mono ? "font-mono break-all" : "break-all"}> | ||
| {value ? value : <em className="text-gray-500">not set</em>} | ||
| </span> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function CreateDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (open: boolean) => void }) { | ||
| const stackAdminApp = useAdminApp(); | ||
| const updateConfig = useUpdateConfig(); | ||
|
|
||
| const formSchema = yup.object({ | ||
| id: yup.string() | ||
| .matches(/^[a-z0-9_-]+$/, "ID can only contain lowercase letters, digits, underscores, and dashes") | ||
| .nonEmpty().label("Connection ID"), | ||
| displayName: yup.string().nonEmpty().label("Display name"), | ||
| domain: yup.string().optional().label("Email domain (for discovery)"), | ||
| idpEntityId: yup.string().nonEmpty().label("IdP Entity ID"), | ||
| // Skip yup's url() — it rejects http://localhost which breaks dev/test setups. | ||
| // The backend SAML wrapper validates the URL on use. | ||
| idpSsoUrl: yup.string().nonEmpty().label("IdP SSO URL"), | ||
| idpCertificate: yup.string().nonEmpty().label("IdP signing certificate (X.509, base64)"), | ||
| allowSignIn: yup.boolean().default(true).label("Enable sign-in"), | ||
| }); | ||
|
|
||
| return ( | ||
| <SmartFormDialog | ||
| open={open} | ||
| onOpenChange={onOpenChange} | ||
| title="Add a SAML connection" | ||
| formSchema={formSchema} | ||
| okButton={{ label: "Create" }} | ||
| cancelButton | ||
| onSubmit={async (values) => { | ||
| // Set the whole connection entry as a single value. Deep dot-keys | ||
| // (e.g. `auth.saml.connections.X.displayName`) get dropped during | ||
| // config normalization when the parent record entry doesn't yet | ||
| // exist — same convention as auth.oauth.providers in the | ||
| // auth-methods page. | ||
| // Normalize domain on write — discovery does case-insensitive | ||
| // matching but does not trim, so trailing whitespace from a | ||
| // pasted value would silently break lookups. | ||
| const normalizedDomain = values.domain?.trim().toLowerCase() || undefined; | ||
| await updateConfig({ | ||
| adminApp: stackAdminApp, | ||
| configUpdate: { | ||
| [`auth.saml.connections.${values.id}`]: { | ||
| displayName: values.displayName, | ||
| allowSignIn: values.allowSignIn, | ||
| domain: normalizedDomain, | ||
| idpEntityId: values.idpEntityId, | ||
| idpSsoUrl: values.idpSsoUrl, | ||
| idpCertificate: (values.idpCertificate ?? "").replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ""), | ||
| }, | ||
| } as Parameters<typeof updateConfig>[0]["configUpdate"], | ||
| // SAML connection fields (cert, IdP URLs) are environment-level | ||
| // (not pushable) — same as OAuth client secrets. | ||
| pushable: false, | ||
| }); | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| function DeleteDialog({ connectionId, displayName, onClose }: { | ||
| connectionId: string | null, | ||
| displayName: string | null, | ||
| onClose: () => void, | ||
| }) { | ||
| const stackAdminApp = useAdminApp(); | ||
| const updateConfig = useUpdateConfig(); | ||
|
|
||
| return ( | ||
| <ActionDialog | ||
| open={connectionId != null} | ||
| onOpenChange={(open) => { if (!open) onClose(); }} | ||
| title="Delete SAML connection?" | ||
| danger | ||
| okButton={{ | ||
| label: "Delete connection", | ||
| onClick: async () => { | ||
| if (!connectionId) return; | ||
| await updateConfig({ | ||
| adminApp: stackAdminApp, | ||
| configUpdate: { [`auth.saml.connections.${connectionId}`]: null } as Parameters<typeof updateConfig>[0]["configUpdate"], | ||
| // SAML connection fields (cert, IdP URLs) are environment-level | ||
| // (not pushable) — same as OAuth client secrets. | ||
| pushable: false, | ||
| }); | ||
| onClose(); | ||
| }, | ||
| }} | ||
| cancelButton | ||
| > | ||
| <Typography> | ||
| Delete <strong>{displayName ?? "this connection"}</strong>? Existing user accounts linked | ||
| via this connection will remain in the database but will no longer be able to sign in. | ||
| </Typography> | ||
| </ActionDialog> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.