Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .claude/skills/test-sso-with-keycloak/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: test-sso-with-keycloak
description: Browser-test learner SAML single sign-on end to end against a real Keycloak IdP on the local stack. Use after any change to learner SSO (apps/api/src/sso-protocol, learner-sso-*, sso-admin, sign-in policy, the admin SSO settings or the storefront SSO button), or when asked to test SSO with a real identity provider.
---

# Test learner SSO with Keycloak

Unit tests and the mock IdP in `apps/api/src/sso-protocol/test-idp.ts` sign responses with our own helpers, so they can't catch how a real IdP behaves. After changing learner SSO, also run this browser test against Keycloak. Testing against Keycloak has already found a turned-off connection that could never be turned back on, and the fact that Keycloak's default client signs only the Response (which we refuse).

Everything runs through `keycloak-sso.sh` in this folder. Run it from the repository root: `.claude/skills/test-sso-with-keycloak/keycloak-sso.sh <command>`. It reads `PUBLIC_API_URL` and `DATABASE_URL` from `apps/api/.env`.

## Requirements

- Docker, and the local stack running: API, worker, admin and storefront, with `SEED=1` and `BILLING_MODE=oss` in `apps/api/.env`.
- **Keycloak must be served over HTTPS with a certificate the browser trusts.** The API only accepts `https` IdP URLs, and the browser refuses self-signed certificates. By default the script serves Keycloak with `tailscale serve` (tailnet-only) on port 9443 under this machine's Tailscale name. Choose another port with `KC_TS_PORT` if 9443 is taken, and never use `tailscale funnel`. Without Tailscale, put Keycloak behind any trusted HTTPS proxy and set `KC_PUBLIC_URL`.
- A browser tool. Use a separate (incognito) profile for the learner, so admin and learner cookies stay apart.

## Run the test

1. `keycloak-sso.sh up`. It prints the IdP metadata URL and the learner login, `lena` / `Lena-pass-123` (email `lena.kc@acme.test`).
2. In the admin, sign in as the seeded owner, `owner@example.com`. The sign-in code is printed in the API log (`[CourseLit OTP]`).
3. Open **Settings → Login providers → Single sign-on → Set up**. Paste the output of `keycloak-sso.sh metadata`, set a button label, and select **Create**. Note the connection ID (`sso_…`) in the page URL.
- A school can show only one SSO connection. If one exists, delete it first. The IdP entity ID can't be changed, so a Keycloak with a different hostname also needs a new connection.
4. `keycloak-sso.sh point <connection>`.
5. In the admin, add the domain `acme.test`. `.test` domains can't hold a real DNS record, so run `keycloak-sso.sh verify-domain <connection>`, then reload the page.
6. Select **Test connection** and sign in at Keycloak as `lena`. The result should show the provider, a persistent subject, `lena.kc@acme.test`, "Lena Keycloak", and "On a verified domain".
7. Select **Turn on**.
8. As the learner, open the school's storefront `/login`, for example `http://school-a.localhost:<storefront port>/login`. Select **Sign in with** followed by the label, sign in at Keycloak, and expect to land on `/dashboard`. `/api/v1/learner/me` should return `lena.kc@acme.test`.

## Checks worth repeating

- Signing in again reuses the same identity: still one row in `school_sso_identities` and one school account.
- The learner's `school_sessions` row has `sso_connection_id` set. No Better Auth `session` row exists for the shadow user, whose email is `sso-…@users.invalid`.
- **Require SSO for these domains**: an email code for `someone@acme.test` is refused on the storefront, while other domains still work.
- **Turn off** ends the learner's session at once (`/learner/me` returns 401).
- Changing the subject attribute, sign-in URL or certificates on a turned-off connection makes it **Draft**, so it can be tested and turned on again.
- `keycloak-sso.sh signing response-only`, then **Test connection**: it fails with `signature_invalid` and the hint about signing the assertion. Restore with `keycloak-sso.sh signing assertion`.
- Saving SSO settings with an old admin sign-in asks the owner to sign in again.

## Gotchas

- Admin writes need a recent sign-in. If the sign-in dialog appears, sign in again and repeat the change.
- `psql -c "a; b"` runs as one transaction: if one statement fails, the earlier updates roll back.
- Keycloak's session sends `SessionNotOnOrAfter` (10 hours by default), so SSO sessions end sooner than the 7-day default. That's expected.
- Deleting a connection keeps its learners. Their shadow users stay until they sign in another way.

## Clean up

Always run `keycloak-sso.sh down` when you finish. It removes the Keycloak container and the `tailscale serve` port, which is a change to the machine's Tailscale config. Close the browser tabs you opened.
115 changes: 115 additions & 0 deletions .claude/skills/test-sso-with-keycloak/keycloak-sso.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Local Keycloak SAML IdP for browser-testing learner SSO.
#
# keycloak-sso.sh up start Keycloak with realm "acme" and learner "lena"
# keycloak-sso.sh point <connection> point the Keycloak client at a CourseLit connection
# keycloak-sso.sh verify-domain <connection> [domain]
# mark a domain verified (default acme.test; no real DNS)
# keycloak-sso.sh signing assertion|response-only
# what Keycloak signs (we require a signed assertion)
# keycloak-sso.sh metadata print the IdP metadata XML (paste it into the admin)
# keycloak-sso.sh down remove Keycloak and the HTTPS proxy
#
# Settings (environment):
# KC_PUBLIC_URL HTTPS origin the browser uses for Keycloak. Default: this
# machine's Tailscale name on port KC_TS_PORT, served with
# `tailscale serve` (tailnet-only). The API only accepts https
# IdP URLs, and the browser must trust the certificate.
# KC_TS_PORT Tailscale serve port when KC_PUBLIC_URL is unset. Default 9443.
# KC_HTTP_PORT Local port Keycloak listens on. Default 8180.
# API_ENV API env file for PUBLIC_API_URL and DATABASE_URL. Default apps/api/.env.
set -euo pipefail

ROOT=$(git rev-parse --show-toplevel)
API_ENV=${API_ENV:-$ROOT/apps/api/.env}
CONTAINER=courselit-sso-keycloak
KC_HTTP_PORT=${KC_HTTP_PORT:-8180}
KC_TS_PORT=${KC_TS_PORT:-9443}
REALM=acme

env_value() { grep -E "^$1=" "$API_ENV" | tail -1 | cut -d= -f2- | tr -d '"'"'"; }

public_url() {
if [[ -n "${KC_PUBLIC_URL:-}" ]]; then echo "${KC_PUBLIC_URL%/}"; return; fi
local name
name=$(tailscale status --self --json | bun -e 'const d=JSON.parse(await Bun.stdin.text()); console.log(d.Self.DNSName.replace(/\.$/, ""))')
echo "https://$name:$KC_TS_PORT"
}

kcadm() { docker exec "$CONTAINER" /opt/keycloak/bin/kcadm.sh "$@"; }
login() { kcadm config credentials --server http://localhost:8080 --realm master --user admin --password admin >/dev/null; }
client_uuid() { kcadm get clients -r "$REALM" --fields id,protocol | bun -e 'const c=JSON.parse(await Bun.stdin.text()).find(x=>x.protocol==="saml"); if(!c) process.exit(1); console.log(c.id)'; }

sql() {
local url; url=$(env_value DATABASE_URL)
if command -v psql >/dev/null; then psql "$url" -v ON_ERROR_STOP=1 -tAc "$1"; return; fi
# No local psql: use the container that publishes the database port.
local parts; parts=$(bun -e 'const u=new URL(process.argv[1]); console.log(u.port||"5432", decodeURIComponent(u.username), u.pathname.slice(1))' "$url")
read -r port user db <<<"$parts"
docker exec "$(docker ps -q --filter "publish=$port" | head -1)" psql -U "$user" -d "$db" -v ON_ERROR_STOP=1 -tAc "$1"
}

case "${1:-}" in
up)
url=$(public_url)
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER" -p "127.0.0.1:$KC_HTTP_PORT:8080" \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.4 start-dev --hostname="$url" --proxy-headers=xforwarded --http-enabled=true >/dev/null
[[ -z "${KC_PUBLIC_URL:-}" ]] && tailscale serve --bg --https="$KC_TS_PORT" "http://127.0.0.1:$KC_HTTP_PORT" >/dev/null
until curl -sf "$url/realms/master" >/dev/null; do sleep 2; done
login
kcadm create realms -s realm=$REALM -s enabled=true >/dev/null
kcadm create users -r $REALM -s username=lena -s email=lena.kc@acme.test -s emailVerified=true \
-s firstName=Lena -s lastName=Keycloak -s enabled=true >/dev/null
kcadm set-password -r $REALM --username lena --new-password 'Lena-pass-123'
# Keycloak's default client signs only the Response; we require a signed assertion.
cid=$(kcadm create clients -r $REALM -s clientId=placeholder -s protocol=saml -s enabled=true \
-s frontchannelLogout=false \
-s 'attributes."saml.server.signature"=true' -s 'attributes."saml.assertion.signature"=true' \
-s 'attributes."saml.client.signature"=false' -s 'attributes."saml.signature.algorithm"=RSA_SHA256' \
-s 'attributes."saml_name_id_format"=persistent' -s 'attributes."saml.force.name.id.format"=true' -i)
for p in email firstName lastName; do
kcadm create "clients/$cid/protocol-mappers/models" -r $REALM -s name=$p -s protocol=saml \
-s protocolMapper=saml-user-property-mapper -s "config.\"user.attribute\"=$p" \
-s "config.\"attribute.name\"=$p" -s 'config."attribute.nameformat"=Basic' >/dev/null
done
echo "Keycloak is up: $url (admin / admin). Learner: lena / Lena-pass-123 (lena.kc@acme.test)"
echo "IdP metadata URL: $url/realms/$REALM/protocol/saml/descriptor"
;;
point)
conn=${2:?connection public id, e.g. sso_01...}
api=$(env_value PUBLIC_API_URL); sp="${api%/}/learner-login/sso/$conn"
login; cid=$(client_uuid)
kcadm update "clients/$cid" -r $REALM -s "clientId=$sp/metadata" -s "redirectUris=[\"$sp/acs\"]" \
-s "attributes.\"saml_assertion_consumer_url_post\"=$sp/acs"
echo "Keycloak client now uses entity ID $sp/metadata and ACS $sp/acs"
;;
verify-domain)
conn=${2:?connection public id}; domain=${3:-acme.test}
sql "UPDATE school_sso_domains d SET verified_at = now(), consecutive_check_failures = 0
FROM school_sso_connections c
WHERE c.id = d.connection_id AND c.public_id = '$conn' AND d.domain = '$domain'
RETURNING d.domain"
;;
signing)
case "${2:-}" in
assertion) value=true ;;
response-only) value=false ;;
*) echo "usage: $0 signing assertion|response-only" >&2; exit 2 ;;
esac
login; cid=$(client_uuid)
kcadm update "clients/$cid" -r $REALM -s "attributes.\"saml.assertion.signature\"=$value"
echo "Keycloak signs: ${2}"
;;
metadata)
curl -sf "$(public_url)/realms/$REALM/protocol/saml/descriptor"; echo
;;
down)
[[ -z "${KC_PUBLIC_URL:-}" ]] && tailscale serve --https="$KC_TS_PORT" off >/dev/null 2>&1 || true
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
echo "Keycloak removed"
;;
*)
sed -n '2,20p' "$0"; exit 2 ;;
esac
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Product-owned. Opens one grouped pull request when a CodeLit Platform
# release is published; all @codelitdev packages release under one version.
# The platform conformance workflow gates the pull request.
#
# The SAML libraries learner SSO signs people in with are pinned exactly and
# have had signature-bypass vulnerabilities, so they get their own grouped
# pull request to review and merge quickly.
version: 2
updates:
- package-ecosystem: "bun"
Expand All @@ -9,7 +13,15 @@ updates:
interval: "daily"
allow:
- dependency-name: "@codelitdev/*"
- dependency-name: "@node-saml/node-saml"
- dependency-name: "xml-crypto"
- dependency-name: "@xmldom/xmldom"
groups:
codelit-platform:
patterns:
- "@codelitdev/*"
saml:
patterns:
- "@node-saml/node-saml"
- "xml-crypto"
- "@xmldom/xmldom"
1 change: 1 addition & 0 deletions apps/admin/app/settings/login-providers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "../page";
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { useParams } from "next/navigation";
import { Suspense } from "react";
import { AuthGate } from "@/components/auth-gate";
import { SsoConnectionSettings } from "@/components/settings/sso-connection-settings";

/** One SSO connection of the school. */
export default function SsoConnectionPage() {
const params = useParams<{ connectionId: string }>();
return (
<AuthGate>
<div className="page-shell">
<div>
<h1 className="text-2xl font-bold tracking-tight">Single sign-on</h1>
<p className="mt-1 text-sm text-muted-foreground">
Let learners sign in through your own identity provider.
</p>
</div>
<Suspense fallback={null}>
<SsoConnectionSettings connectionId={params.connectionId} />
</Suspense>
</div>
</AuthGate>
);
}
19 changes: 17 additions & 2 deletions apps/admin/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Globe,
Info,
Key,
LogIn,
Mail,
Palette,
RotateCcw,
Expand All @@ -26,6 +27,7 @@ import { AiAssistantSettings } from "@/components/settings/ai-assistant-settings
import { ApiKeySettings } from "@/components/settings/api-key-settings";
import { BrandingSettings } from "@/components/settings/branding-settings";
import { EmailDeliveryNotice } from "@/components/mails/email-delivery-notice";
import { LoginProviderSettings } from "@/components/settings/login-provider-settings";
import { NotificationSettings } from "@/components/settings/notification-settings";
import { TeamSettings } from "@/components/settings/team-settings";
import { CodeInjectionSettings } from "@/components/website/code-injection-settings";
Expand Down Expand Up @@ -58,7 +60,7 @@ import {
import { Textarea } from "@/components/ui/codelit/textarea";
import currencies from "@/data/currencies.json";

const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "api-keys", "ai-assistants"] as const;
const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants"] as const;
const MAIL_SETTINGS_TABS = ["delivery"] as const;
const WEBSITE_SETTINGS_TABS = ["branding", "code-injection", "domain"] as const;
type SettingsMode = "general" | "website" | "mails";
Expand Down Expand Up @@ -144,7 +146,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {
}
: {
title: "Settings",
description: "Manage payments, team access, notifications, and API keys for this school.",
description: "Manage payments, team access, notifications, login providers, and API keys for this school.",
};

function selectWebsiteTab(tab: string) {
Expand Down Expand Up @@ -421,6 +423,11 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {
label: "Notifications",
icon: <Bell className="size-4" />,
},
{
value: "login-providers",
label: "Login providers",
icon: <LogIn className="size-4" />,
},
// API keys belong to the school; only its owner manages them.
...(isOwner
? [{ value: "api-keys", label: "API keys", icon: <Key className="size-4" /> }]
Expand Down Expand Up @@ -734,6 +741,14 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) {
) : null}
</PlatformTabsContent>

<PlatformTabsContent value="login-providers" className="w-full">
{mode === "general" && selectedTab === "login-providers" ? (
<div className="w-full pt-4">
<LoginProviderSettings />
</div>
) : null}
</PlatformTabsContent>

<PlatformTabsContent value="api-keys" className="w-full">
{mode === "general" && selectedTab === "api-keys" && isOwner ? (
<div className="w-full pt-4">
Expand Down
9 changes: 8 additions & 1 deletion apps/admin/components/permission-denied-feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { toast } from "sonner";

export const PERMISSION_DENIED_MESSAGE =
"You do not have permission to perform that action.";
const SELF_EXPLAINING_REASONS = new Set(["recent_authentication_required", "plan_required"]);
const PERMISSION_DENIED_TOAST_ID = "courselit-permission-denied";

export function showPermissionDeniedToast() {
Expand Down Expand Up @@ -38,7 +39,13 @@ export function PermissionDeniedFeedback() {
) => {
const response = await originalFetch(input, init);
if (response.status === 403 && isCourseLitAdminApiRequest(input)) {
showPermissionDeniedToast();
// Some 403s are not about permission; the screen explains those itself.
const reason = await response
.clone()
.json()
.then((body: { details?: { reason?: unknown } }) => body?.details?.reason)
.catch(() => null);
if (!SELF_EXPLAINING_REASONS.has(reason as string)) showPermissionDeniedToast();
}
return response;
};
Expand Down
Loading
Loading