|
|
||
|---|---|---|
| .forgejo/workflows | ||
| .vscode | ||
| public | ||
| scripts | ||
| src | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| .prettierignore | ||
| bun.lock | ||
| bunfig.toml | ||
| deploy-portainer.sh | ||
| docker-compose.yml | ||
| Dockerfile | ||
| eslint.config.js | ||
| package.json | ||
| postcss.config.cjs | ||
| qwik.env.d.ts | ||
| README.md | ||
| rules.md | ||
| tailwind.config.cjs | ||
| tsconfig.json | ||
| vite.config.ts | ||
| vitest.setup.ts | ||
Auth Orbit – Keycloak SSO Portal (Qwik)
Auth Orbit is a lightweight, SSR-enabled portal that helps users discover and launch applications protected by Keycloak. It supports a request‑access workflow for discoverable apps and includes an Admin UI to manage app visibility and approvals.
Built with Qwik + Qwik City. Runtime uses Bun; tests run Vitest via Bun.
Getting Started
- Prerequisites
- Bun 1.x (Node 18+/20+ compatible)
- A Keycloak realm with a public OIDC client (no secret) for the portal
- Optional: a confidential admin client if you want Admin UI features (server‑side only)
- Install
bun install
- Configure environment
Copy .env.example to .env and set the minimal required client-side variables:
VITE_KEYCLOAK_BASE_URL=https://keycloak.example.com
VITE_KEYCLOAK_REALM=main
VITE_KEYCLOAK_CLIENT_ID=portal
VITE_KEYCLOAK_REDIRECT_URI=http://localhost:5173/auth/callback
VITE_KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:5173/
VITE_KEYCLOAK_SCOPE="openid profile email"
Optional server-side variables (enable Admin UI and server calls):
KEYCLOAK_ADMIN_CLIENT_ID=portal-admin
KEYCLOAK_ADMIN_CLIENT_SECRET=... # keep server-side only
# Optional: portal admin group name
AUTH_ORBIT_ADMIN_GROUP=auth-orbit-admin
- Run the app (dev)
bun start
Visit http://localhost:5173.
- Keycloak quick setup (public client)
- Create/select realm → note
VITE_KEYCLOAK_REALM - Create client (OIDC) →
VITE_KEYCLOAK_CLIENT_ID - Client authentication: OFF (public)
- Standard flow: ON; PKCE: S256
- Valid redirect URIs:
http://localhost:5173/auth/callback - Post-logout redirect URIs:
http://localhost:5173/ - Web origins: your dev origin (or
+)
That’s it for basic login/logout and the user dashboard.
Features
- User dashboard that lists visible apps with safe defaults
- Request access action for discoverable apps without access
- Admin UI to adjust app attributes (visibility/access/requests) and approve/deny requests
- Group-based access model using per-app groups and attributes
- SSR-first Qwik app with streaming loaders and good UX patterns
Configuration
Create a .env from .env.example and fill values:
VITE_KEYCLOAK_BASE_URL: e.g.https://keycloak.example.comVITE_KEYCLOAK_REALM: realm name, e.g.mainVITE_KEYCLOAK_CLIENT_ID: public client idVITE_KEYCLOAK_REDIRECT_URI: e.g.http://localhost:5173/auth/callbackVITE_KEYCLOAK_LOGOUT_REDIRECT_URI: e.g.http://localhost:5173/VITE_KEYCLOAK_SCOPE:openid profile email(default)
Optional server-only (not exposed to client):
KEYCLOAK_ADMIN_CLIENT_IDKEYCLOAK_ADMIN_CLIENT_SECRET
Optional access-control env:
AUTH_ORBIT_ADMIN_GROUP— group name that grants admin privileges in addition to roles (default:auth-orbit-admin).
These enable admin features like listing clients on the dashboard.
Keycloak setup (where to get values)
Follow these steps in the Keycloak Admin Console to configure a client for this app and to find values for your .env:
- Create or select a Realm
- Use your existing realm or create a new one. The value is your
VITE_KEYCLOAK_REALM.
- Note the Base URL
VITE_KEYCLOAK_BASE_URLis the Keycloak server base, e.g.https://keycloak.example.com.- You can verify realm endpoints at:
{BASE_URL}/realms/{REALM}/.well-known/openid-configuration.
- Create a Client (OIDC)
- Clients → Create client
- Client type: OpenID Connect
- Client ID: e.g.
portal(this becomesVITE_KEYCLOAK_CLIENT_ID).
- Client Capability/Settings
- Standard flow: ON (Authorization Code)
- Direct access grants: OFF (optional)
- Service accounts: OFF (not needed for SPA)
- Client authentication: OFF (this makes it a public client)
- Proof Key for Code Exchange (PKCE): S256 (recommended)
- Valid redirect URIs: include your callback, e.g.
http://localhost:5173/auth/callback - Post-logout redirect URIs: include, e.g.
http://localhost:5173/ - Web origins: include your origin, e.g.
http://localhost:5173(or+to match redirect origins)
- Scopes
- Ensure the client has
openidand optionallyprofile,emailin default scopes. This maps toVITE_KEYCLOAK_SCOPE.
- Map values to
.env
VITE_KEYCLOAK_BASE_URL→ Keycloak base URLVITE_KEYCLOAK_REALM→ Realm nameVITE_KEYCLOAK_CLIENT_ID→ Client ID you createdVITE_KEYCLOAK_REDIRECT_URI→ Your app callback URLVITE_KEYCLOAK_LOGOUT_REDIRECT_URI→ Where to land after logoutVITE_KEYCLOAK_SCOPE→openid profile email(default)
Do we need a client secret?
- For this application’s browser-based login (SPA with PKCE), no client secret is required and must not be used in the browser. Configure the client as a public client (Client authentication OFF) with Standard flow ON and PKCE S256.
- A client secret is only required for confidential clients used from trusted server-side code. In this repo, optional admin features (listing clients, etc.) can use a separate admin client configured as confidential; those credentials must be provided server-side only via:
KEYCLOAK_ADMIN_CLIENT_IDKEYCLOAK_ADMIN_CLIENT_SECRET
How to obtain KEYCLOAK_ADMIN_CLIENT_ID and KEYCLOAK_ADMIN_CLIENT_SECRET
Create a separate confidential client for server-side admin access:
- Create client (OIDC)
- Clients → Create client
- Client type: OpenID Connect
- Client ID: e.g.
portal-admin(this will beKEYCLOAK_ADMIN_CLIENT_ID).
- Configure as confidential
- Client authentication: ON (confidential)
- Standard flow: OFF (not needed for client credentials)
- Service accounts: ON (enables client credentials grant)
- Direct access grants: OFF
- Obtain client secret
- Open the client → Credentials tab → copy the Client Secret and set
KEYCLOAK_ADMIN_CLIENT_SECRET.
- Grant admin roles to the service account
- Open the client → Service account roles
- Assign realm roles from the
realm-managementclient as needed, for example:view-clients(read clients)manage-clients(create/update clients, only if required)view-users(read users, if required)query-groups,query-usersas needed
Notes:
- Scope roles to the minimum necessary for your dashboard features.
- These credentials are used only server-side and must never be exposed to the browser.
Required permissions (service account client and/or admin user)
This app calls specific Keycloak Admin REST endpoints. Grant the following minimal roles from the realm-management client depending on which features you enable. You can assign these to:
- The confidential service account client (for server-to-server calls), and/or
- A human admin user (if you prefer user-based admin access).
• Dashboard: list visible apps
- Endpoints:
GET /admin/realms/{realm}/clients - Roles:
view-clients
• Admin Apps: list apps + show counts + group existence
- Endpoints:
GET /admin/realms/{realm}/clientsGET /admin/realms/{realm}/groups?search=...GET /admin/realms/{realm}/groups/{groupId}GET /admin/realms/{realm}/groups/{groupId}/members
- Roles:
view-clients,query-groups,view-users(orquery-users)
• Admin Apps: update app attributes (visibility/access/request)
- Endpoints:
PUT /admin/realms/{realm}/clients/{clientUuid} - Roles:
manage-clients
• Approve/Deny requests (move users between groups)
- Endpoints:
PUT /admin/realms/{realm}/users/{userId}/groups/{groupId}(add)DELETE /admin/realms/{realm}/users/{userId}/groups/{groupId}(remove)GET /admin/realms/{realm}/groups?search=...(lookup)POST /admin/realms/{realm}/groups(create missing groups)
- Roles:
manage-users(required to change group membership),query-groups(lookup), andmanage-realm(only if you want the app to auto-create missing groups)
• Optional: broad admin
- As an alternative to the granular roles above, you can assign composite
realm-adminto a dedicated admin user. This is broader than necessary; use it only if your org’s policy permits.
Notes:
- Exact role names come from the
realm-managementclient. Names may vary slightly across Keycloak versions; prefer the least-privileged combination that allows the endpoints above. - If you do not grant
manage-realm, the UI will still work but the “ensure groups” action will fail when groups are missing; create groups out-of-band or grant the role temporarily.
How to make a user an "admin"
There are multiple approaches; choose one that fits your org model. Two common options:
- Create a realm role
portal-admin(recommended)
- Realm roles → Create role → Name:
portal-admin. - Make it a composite role and add minimal roles from
realm-management(e.g.view-clients, optionallymanage-clients,view-users). - Assign
portal-adminto users who should administer the portal.
- Use a group
portal-admins
- Create group
portal-admins. - In Group → Role mappings, add the same
realm-managementroles as above. - Add users to this group to grant admin privileges.
Application-side usage: The portal includes an Admin UI at /admin, gated by realm role/group. Users with the portal-admin role or membership in portal-admins (and/or the configured admin group) will see an “Admin” link on the dashboard. Admin pages call server-side endpoints that use the Admin client credentials.
Access control and approvals
This portal implements a group-based access control model using Keycloak client attributes and per-app groups.
- Per-app groups created/used:
- Users:
app:{clientId}:users - Requests:
app:{clientId}:requests
- Users:
- Client attributes (set per client):
ao_visibility:public|discoverable|hiddenao_access_mode:all|group|userao_request_enabled:true|falseao_group_id: optional group id override
User flow:
- The dashboard hides
hiddenapps for non-admins and showspublic/discoverableones. - If a user lacks access and
ao_request_enabledis true, they can request access from the app card.
Admin flow (via /admin):
- View all apps and see counts of pending requests and users.
- Update each app’s
visibility,accessMode, and whether requests are enabled. - Approve/Deny requests, which moves users from
app:{clientId}:requeststoapp:{clientId}:users(approve) or removes from requests (deny).
Development
bun install
bun start # dev server (SSR)
Visit http://localhost:5173.
Production build and local preview
bun run build # full client+server build
bun run preview # serve production preview on http://localhost:4173
Testing and linting
bun run lint # ESLint
bun run build.types # TypeScript type-check
bun run test:vitest # Vitest (executed via Bun)
Testing notes
- Use Qwik's testing utilities from
@builder.io/qwik/testing(createDOM,userEvent) for component tests. This avoids SSR manifest coupling. - Use Vitest mocks (
vi.mock,vi.fn) and Qwik City's<QwikCityMockProvider>to mock routes/loaders/navigation when needed. - Only use
@builder.io/qwik/serverrenderToStringwhen asserting SSR HTML snapshots. In that case, you may need a prebuilt client manifest; otherwise prefercreateDOM.
Authentication flows
- Login:
GET /auth/loginstarts OIDC auth with PKCE. - Callback:
GET /auth/callbackexchanges code, sets session. - Logout:
GET /auth/logoutclears session and redirects via Keycloak. - Dashboard:
GET /dashboardprotected page shows visible applications for the user. It sources data fromGET /api/apps(user-scoped) and no longer requires admin credentials client-side.
Admin UI
- Entry point:
/admin(visible when the user has theportal-adminrole, is inportal-admins, or matches the configured admin group). - Manage app:
/admin/apps/:clientIdto adjust visibility/access and approve/deny requests.
Admin API endpoints
GET /api/admin/apps— list apps with attributes and counts.POST /api/admin/apps/:clientId/attributes— updateao_visibility,ao_access_mode,ao_request_enabled,ao_group_id.POST /api/admin/apps/:clientId/approve— body:{ userId }.POST /api/admin/apps/:clientId/deny— body:{ userId }.GET /api/admin/apps/:clientId/requests— list pending request members.
User-facing API
GET /api/apps— returns visible applications for the current user (includesvisible,hasAccess, andrequestable).
Request Access
/request-access is wired to Keycloak:
- Validates the user session.
- Checks that the target app has
ao_request_enabled. - Adds the user to
app:{clientId}:requestsfor admin review.
Docker
Build a production-like preview image:
docker compose build \
--build-arg VITE_KEYCLOAK_BASE_URL=$VITE_KEYCLOAK_BASE_URL \
--build-arg VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM \
--build-arg VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID \
--build-arg VITE_KEYCLOAK_REDIRECT_URI=$VITE_KEYCLOAK_REDIRECT_URI \
--build-arg VITE_KEYCLOAK_LOGOUT_REDIRECT_URI=$VITE_KEYCLOAK_LOGOUT_REDIRECT_URI \
--build-arg VITE_KEYCLOAK_SCOPE="openid profile email"
docker compose up
The app will be available at http://localhost:4173.
Notes:
- The image uses Qwik preview build served by
vite preview. For real SSR in production, add an adapter (e.g.bun qwik add adapter-node) and adjustDockerfileaccordingly. - Server-only admin credentials are runtime environment variables on the container:
KEYCLOAK_ADMIN_CLIENT_ID,KEYCLOAK_ADMIN_CLIENT_SECRET. - Ports are configurable via env: set
HOST_PORTandPORT(defaults to 4173:4173). Example:HOST_PORT=8080 PORT=4173 docker compose up # Now available at http://localhost:8080
CI/CD and Deployment (Forgejo + Portainer)
This repo includes a Forgejo CI workflow at .forgejo/workflows/ci.yml and a Portainer deploy helper script at deploy-portainer.sh.
• Secrets to add in Forgejo repo settings
PORTAINER_API_KEY(for Portainer API calls used by the script)PORTAINER_WEBHOOK_URL(your Portainer stack webhook URL)- Optional:
PORTAINER_ENDPOINT_ID(defaults to1) - Optional:
REGISTRY_USERNAME,REGISTRY_PASSWORD(only if your registry requires auth)
Note: The CI workflow currently skips docker/login-action because the registry does not require authentication. If you later enable registry auth, add the above credentials as secrets and re-enable the login step in .forgejo/workflows/ci.yml.
• Update env in .forgejo/workflows/ci.yml as needed
REGISTRY: e.g.registry.aqueous.networkIMAGE_NAME: e.g.auth-orbitPORTAINER_URL: e.g.https://port.aqueous.networkPORTAINER_STACK_ID_STAGING/PORTAINER_STACK_ID_PRODUCTION: your stack IDs
• Branch behavior
develop→ deploy-stagingmain→ deploy-production- Build pushes tags:
${sha},${ref_name}, andlatest
• CI test mode
- CI runs Vitest via Bun. The script
scripts/run-bun-tests-ci.shis used by the workflow. - To replicate locally:
NODE_ENV=production bun run test:ci.
• Deploy script (deploy-portainer.sh)
- Reads
PORTAINER_URL,PORTAINER_API_KEY, andWEBHOOK_URLfrom env/secrets - Usage example:
WEBHOOK_URL="https://port.example/api/stacks/webhooks/xxxxxxxx" \ PORTAINER_API_KEY="<redacted>" \ ./deploy-portainer.sh <STACK_ID> staging <IMAGE_TAG> - Requirements:
curl,jq(installed in CI jobs)
• Build args / app config
- The
DockerfiledefinesARGfor theVITE_KEYCLOAK_*variables. If you need these at build time in CI, addbuild-argsto thedocker/build-push-actionstep or configure them via your registry/runtime.
• Optional improvements
- Add a
.dockerignoreto speed up Docker builds - Tune Buildx cache settings if builds are frequent
Advanced: Testing and environment
- Tests run Vitest via Bun. Preload and aliases are set in
bunfig.tomlwhere applicable. - Prefer
@builder.io/qwik/testingfor component rendering. If you intentionally test SSR HTML viarenderToString, you may need to prebuild the client manifest and import it fromtests/ssr-manifest.ts. - For CI, see the workflow in
.forgejo/workflows/ci.yml.
Security
- Session and PKCE state/verifier are stored in HTTP-only cookies.
- Admin credentials never ship to the client; only used server-side.
Tech stack
- Qwik + Qwik City
- Tailwind CSS
- Vitest + ESLint + TypeScript