Olympus Docs
CookbookEnterprise SSO

Build an app launcher with SSO

A dashboard from which users open apps without re-auth

If users have multiple internal apps (admin panel, billing, analytics, support tool), an "app launcher", like Google Workspace's grid or Okta's dashboard, improves UX. Each tile launches into the app already signed in.

Pattern

                ┌─ User signs in once at Olympus ─┐
                │                                  │
                ▼                                  │
        app-launcher.your-domain                   │
        (lists apps user has access to)            │
                │                                  │
                ├─► click "Billing" ─► /billing  ──┤
                ├─► click "Admin"   ─► /admin    ──┤
                ├─► click "Support" ─► /support  ──┤
                │                                  │
                └──────────────────────────────────┘
        (each app uses the existing Olympus session, no re-auth)

Implementation

App-launcher is just another Olympus-protected app. When user is signed in, it shows tiles.

Each tile is an <a href> to the target app's URL. The target app:

  1. Has its own OAuth2 client (Authorization Code flow).
  2. On hit, checks for existing session at Olympus.
  3. If signed in, gets a token silently; if not, redirects to login.

For users who already have an active Olympus session, the OAuth2 dance is silent, no UI shown.

The launcher UI

// app-launcher/app/page.tsx
import { getSession } from "@/lib/auth";
import { getAccessibleApps } from "@/lib/apps";

export default async function Launcher() {
  const session = await getSession();
  const apps = await getAccessibleApps(session.identity.id);
  return (
    <div className="grid grid-cols-4 gap-4">
      {apps.map(app => (
        <a key={app.id} href={app.url} className="card">
          <img src={app.icon} />
          <span>{app.name}</span>
        </a>
      ))}
    </div>
  );
}

Per-user app list

Different users see different apps based on their roles:

async function getAccessibleApps(identityId: string) {
  const identity = await kratos.getIdentity(identityId);
  const allApps = [
    { id: "billing", name: "Billing", url: "/billing", requiresRole: "admin" },
    { id: "admin", name: "Admin", url: "/admin", requiresRole: "admin" },
    { id: "support", name: "Support", url: "/support", requiresRole: "support" },
    { id: "analytics", name: "Analytics", url: "/analytics", requiresRole: "user" },
  ];
  return allApps.filter(app => identity.traits.role === app.requiresRole 
                              || identity.traits.role === "admin");
}

In a more dynamic setup, store app access in Athena's settings vault or per-tenant config.

Recent activity

Show a "Recently used" section based on user behavior:

const recent = await db`
  SELECT app_id, MAX(visited_at) as last_visit
  FROM app_visits
  WHERE identity_id = ${identityId}
  GROUP BY app_id
  ORDER BY last_visit DESC
  LIMIT 5
`;

Log a visit each time user clicks a tile.

Admin: app management

Build an admin page where you can:

  • Add apps (name, URL, icon, OAuth client config).
  • Set access rules (which roles, which tenants).
  • Deactivate apps.

Stored in apps table.

Universal logout

When user clicks "Sign out" in the launcher, sign them out everywhere:

  1. Launcher's session → cleared.
  2. Olympus session → cleared (Kratos logout).
  3. All registered RP apps → front-channel logout (if configured).

See OpenID RP-initiated logout for front-channel logout setup.

Common patterns

Tenant-scoped launcher

A user might belong to multiple tenants. Launcher shows current tenant's apps + a switcher.

<TenantSwitcher tenants={user.tenants} active={activeTenantId} />

Switching tenants:

  • Updates a cookie / session attribute.
  • Reloads the launcher to show that tenant's apps.

Federated launchers (multi-org)

For SaaS where the launcher itself is a separate product, each customer org has their own launcher with their own apps. Branding, content, layout customizable per tenant.

Bookmark / shortcut keys

For power users, keyboard shortcuts:

useHotkeys("g a", () => navigate("/admin"));
useHotkeys("g b", () => navigate("/billing"));
useHotkeys("g s", () => navigate("/support"));

For 20+ apps, add search:

const [query, setQuery] = useState("");
const filtered = apps.filter(a => a.name.toLowerCase().includes(query.toLowerCase()));

Cmd+K opens search. Standard pattern.

What can go wrong

Stale token cache

If user's role changes (admin → user), the launcher might still show admin tiles until next page refresh. Solution: short cache, or invalidate on session.

Silent OAuth fails

If a target app's silent OAuth2 fails (cookie cleared, third-party block), it shows a login. Brief flicker, but user is signed in to Olympus so it's silent-ish.

Cross-domain issues

If launcher is on apps.your-domain.com and target apps are on billing.your-domain.com, cookies don't share. Each app re-auths via OAuth2 (which is what we want).

If apps are on totally different domains (billing.other-corp.com), front-channel logout might not work due to third-party cookie blocking.

On this page