Build an enterprise SSO portal
One sign-in to access multiple internal apps
For organizations with many internal tools (HR, ops, monitoring), an SSO portal lets users sign in once and click into each app. Olympus as the central IdP.
Architecture
User signs into Olympus
↓
Lands on portal: app launcher
↓
Clicks "Salary management"
↓
salary-mgmt.your-corp.com (OAuth2 client)
↓
Already signed in (silent OAuth2)
↓
Logged in, no second auth.Same pattern for every app.
Apps as OAuth2 clients
Each app: a Hydra OAuth2 client.
hydra create client \
--name "Salary Management" \
--grant-types authorization_code,refresh_token \
--scope "openid offline_access profile email" \
--redirect-uri "https://salary-mgmt.your-corp.com/callback" \
--post-logout-redirect-uri "https://salary-mgmt.your-corp.com/" \
--skip-consent # first-party, no consent promptEach app: own client_id, own callback.
Portal UI
// portal/app/page.tsx
import { getApps } from "@/lib/apps";
export default async function Portal() {
const session = await getSession();
const apps = await getApps(session.identity);
return (
<Grid columns={4}>
{apps.map(app => (
<Tile key={app.id}>
<a href={app.url}>
<img src={app.icon} />
<h3>{app.name}</h3>
</a>
</Tile>
))}
</Grid>
);
}App-config table
CREATE TABLE apps (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
description TEXT,
required_role TEXT, -- or roles array
enabled BOOLEAN DEFAULT true
);App metadata. Admin can add/remove via UI.
Access control per app
Filter apps by user's role:
function getAccessibleApps(identity) {
return apps.filter(app =>
!app.required_role || identity.traits.role === app.required_role
);
}User without admin role doesn't see "Admin" tile.
First-party silent flow
User clicks app → OAuth2 to Hydra. Hydra has Kratos session → silent issuance.
No login screen, no consent. From user's POV: portal → app instantly.
SSO logout
User clicks "Sign out" in portal:
async function signOut() {
// 1. Sign out of Kratos
await kratos.updateLogoutFlow(...);
// 2. Broadcast logout to all RPs via front-channel
await Promise.all(rpAppUrls.map(url =>
fetch(`${url}/_/oidc-logout?sid=${sessionId}`).catch(() => {})
));
// 3. Redirect to logged-out page
window.location.href = "/";
}Each RP detects via front-channel logout URI. Their session ends too.
Recent / favorites
<section>
<h2>Recent</h2>
<Grid>
{recentApps.map(app => <Tile {...app} />)}
</Grid>
</section>
<section>
<h2>All apps</h2>
<Grid>
{allApps.map(app => <Tile {...app} />)}
</Grid>
</section>Track clicks:
function onAppClick(appId) {
fetch("/api/track-click", { method: "POST", body: JSON.stringify({ appId }) });
// Then navigate
}Personalization.
Search
<input
placeholder="Search apps..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{apps.filter(a => a.name.includes(query)).map(...)}20+ apps → search helpful.
Bookmarks per user
Let user favorite apps:
CREATE TABLE app_favorites (
identity_id UUID,
app_id UUID,
PRIMARY KEY (identity_id, app_id)
);Show favorites first in launcher.
Subdomain pattern
All apps on subdomains of your-corp.com:
portal.your-corp.com, launcher.app1.your-corp.com,app2.your-corp.com, etc.
Caddy:
*.your-corp.com {
reverse_proxy auth-portal:3000
}Or per-app vhosts.
Branding consistency
All apps share:
- Logo (top-left).
- Help link.
- Sign-out button.
- User avatar / menu.
Component library shared:
// @your-corp/internal-app-shell
import { Shell } from "@your-corp/internal-app-shell";
export default function App() {
return (
<Shell>
{/* app-specific content */}
</Shell>
);
}Onboarding new apps
When new internal app is built:
- Engineer registers OAuth2 client via Athena.
- Adds entry to apps table.
- App imports the shell library.
- Configures OAuth2 client_id.
- Tile appears in launcher.
- Sign-in works silently.
Document in your dev runbook.
Per-team filtering
<Filter>
<FilterChip>HR</FilterChip>
<FilterChip>Engineering</FilterChip>
<FilterChip>Finance</FilterChip>
</Filter>Group apps by tag. Bigger orgs benefit.
Notifications
Optionally show in-portal notifications:
<header>
<Notifications />
</header>Notify of: maintenance windows, new app availability, etc.
SAML for legacy apps
Some legacy apps speak SAML, not OIDC. Run Dex or Keycloak as SAML bridge:
User → Portal → Click legacy-app
→ Bridge (SAML-OIDC translator)
→ Hydra (OIDC) ↔ Olympus identity
→ Legacy appEach legacy app's SAML metadata is configured in bridge.
See SAML-OIDC bridge.
Single-page or per-app
Two architectures:
- Per-app: each app is a separate web app. Hard links between.
- Single SPA: all apps loaded as routes in one. Subtler.
For internal SSO portal: per-app is normal. Apps can ship independently.