OutlabsAuth
Auth

Multi-Frontend Support

One mount, several first-party frontends — profiles, resolution, and sign-in gating.

One outlabsAuth mount can serve several first-party frontends of the same platform — an admin console plus a customer portal, say. Password-reset mail, invite links, magic links, OAuth landings, and issued sessions must all land on the frontend the account actually belongs to. Multi-frontend support (DD-059, 0.1.0a25+) makes that a designed-in concept instead of three host workarounds.

Scope boundary, stated up front: one deployment = one platform = one user pool, with N first-party frontends. Profiles decide where links land, how mail is branded, and which users may authenticate through which app — they are not tenants and create no credential isolation (RBAC and root-entity scoping remain the data boundary). Running several unrelated SaaS products off one deployment stays out of scope: genuinely distinct products get separate deployments.

Everything below is opt-in. A minimal host with no profiles sees zero behavior change — single-composer mail construction is byte-identical, and requests without an app key behave exactly as before.

The moving pieces

PieceWhat it does
FrontendProfileImmutable declaration of one frontend: key, branding, registered origins, per-flow route templates, redirect policy, accepted_audiences
FrontendProfileRegistryStartup-validated set of profiles (duplicate keys, non-HTTPS origins, and malformed templates are rejected at wiring time)
FrontendProfileResolverThe one canonical resolution component — host-supplied resolver over a typed context, resolved once per operation, registered keys only
profile_idThe resolved key, persisted downstream: mail intents, challenge rows, OAuth state, session records, audit events
azp claimSession provenance: which profile minted this session, re-validated at refresh rotation

A profile — not the resolver, not the caller — owns URL construction and branding. Selecting a profile for a flow it declares unsupported (route = None) is a wiring error at startup or a fail-closed delivery error at send time, never a guessed link.

Declaring profiles

from outlabs_auth.frontend import (
    FrontendProfile,
    FrontendProfileRegistry,
    FrontendProfileResolver,
    FrontendRoutes,
    RedirectPolicy,
    route_by_root_entity_slug,
)

registry = FrontendProfileRegistry([
    FrontendProfile(
        key="console",                          # stable, non-secret
        app_name="Operations Console",          # branding for subjects/bodies
        public_origins=("https://console.example.com",),
        routes=FrontendRoutes(
            login="/login",
            password_reset="/recovery/{token}",          # path placement
            accept_invite="/accept-invite?token={token}", # query placement
            magic_link="/auth/magic-link?token={token}",
            oauth_success="/auth/oauth/callback",
            oauth_error="/login",
        ),
        accepted_audiences=("internal",),        # partitioned: staff only
        support_email="support@example.com",
    ),
    FrontendProfile(
        key="portal",
        app_name="Agent Portal",
        public_origins=("https://portal.example.com",),
        routes=FrontendRoutes(
            login="/sign-in",
            password_reset="/recovery/{token}",
            accept_invite=None,                  # portal has no invite page —
                                               # invites fail closed, never guessed
        ),
        accepted_audiences=("agent",),
    ),
])

resolver = FrontendProfileResolver(
    registry,
    route_by_root_entity_slug({"internal-org": "console", "agent-practice": "portal"}),
)

Route-template rules:

  • Token flows (password_reset, accept_invite, magic_link) need exactly one {token} placeholder; both ?token={token} and /recovery/{token} placements are first-class.
  • Origins must be absolute HTTPS outside local development (FrontendProfileRegistry(..., local_dev=True) permits http://localhost:3000).
  • Profiles are immutable after startup.

The resolver is host code

The audience key differs per host — root-entity slug, entity type, role, membership scope, or the requested profile — so the mapping stays host-owned. The resolver receives a typed FrontendResolutionContext (flow kind, recipient, root_entity_id/slug/type, actor and target entity for invites, the requested profile key, request origin as evidence, never authority, and the caller's session when one exists). It may be async and may query host data, but returns only a registered key.

Library helpers cover the observed cases:

from outlabs_auth.frontend import route_by_root_entity_slug, route_by_root_entity_type

route_by_root_entity_slug({"internal-org": "console", "agent-practice": "portal"})
route_by_root_entity_type(
    {"agent_practice": "portal", "brokerage": "portal"},
    slug_overrides={"internal-org": "console"},   # canonical slug beats type
)

Both take honor_requested=True (a frontend-originated app key is accepted when identity has no opinion; a requested key that contradicts identity is a hard mismatch) and on_unresolved=None (the explicit-unresolved posture — declare a profile for genuinely unresolvable contexts, or keep fail-closed).

Failure policy is fail closed. Unknown profile, unsupported flow, resolver exception, or user/profile mismatch → no send, a structured delivery-failure result, and a log record. Enumeration-resistant endpoints keep their opaque 204/202 outward response. A declared default profile (FrontendProfileResolver(..., default="console")) applies only to genuinely unambiguous contexts — never as an exception fallback. Reset-confirmation notices may fall back to the default's neutral, link-free message so the security signal still reaches the user.

Mail per audience from one mount

from outlabs_auth.mail import ComposedAuthMailService, DefaultAuthMailComposer

mail_service = ComposedAuthMailService(
    provider=provider,
    composers={
        "console": DefaultAuthMailComposer.from_profile(registry.get("console")),
        "portal": DefaultAuthMailComposer.from_profile(registry.get("portal")),
    },
    resolver=resolver,
    # default="console",   # optional declared default (see failure policy)
)

auth = EnterpriseRBAC(
    database_url=..., secret_key=...,
    transactional_mail_service=mail_service,
    frontend_resolver=resolver,   # also powers challenges, OAuth, and the sign-in gate
)

Each send resolves the recipient's profile once and composes with that profile's branding and route templates — forgot-password mail for an internal user lands on console.example.com, an agent's on portal.example.com, from one mount. Intents now also carry root_entity_id/slug/type (the library enriches them from the user row + request-scoped cache), and invite mail finally gets its advertised target_entity_name / inviter_email / role_names metadata from persisted invite state.

ComposedAuthMailService(provider=..., composer=...) — the single-composer form — is unchanged.

Challenges: registered destinations, canonical next_url

Forgot-password, magic-link, and access-code requests accept an optional app field — a registered profile key, never a URL:

POST /auth/magic-link/request
{ "email": "agent@example.com", "app": "portal", "redirect_url": "/dashboard" }

At request time the library resolves the profile (fail closed on unknown keys, identity mismatches, disallowed return targets, or a profile with no landing route for the flow), validates the return target against the profile's RedirectPolicy (relative path, or an absolute URL on a registered origin), and persists profile_id + the canonical target on the challenge row. Verification returns the canonical destination on LoginResponse:

{
  "access_token": "",
  "refresh_token": "",
  "token_type": "bearer",
  "expires_in": 900,
  "next_url": "https://portal.example.com/dashboard"
}

The frontend navigates from the server-validated next_url instead of trusting a redirect value in its own URL query. Raw redirect_url remains accepted for one compatibility window — validated when profiles are configured — and is then retired.

OAuth: profile-bound state

/authorize accepts a registered app key; the signed and persisted state binds profile_id plus a unique flow nonce, and per-profile binding cookies let concurrent same-provider flows from different frontends coexist. The callback consumes state, then resolves the bound profile's registered oauth_success / oauth_error landings. Login and association routers both implement this, and both factories are now exported from outlabs_auth.routers:

from outlabs_auth.routers import get_oauth_router, get_oauth_associate_router

Construction-time success_redirect_url / error_redirect_url keep working as the single-profile degenerate case. Details: OAuth & Social Login.

Sessions: azp provenance and sign-in gating

Every minted session records the resolved profile key as an azp-style claim (authorized party) on the tokens and the refresh row, preserved and re-validated at rotation. aud stays the platform/resource audience — there is deliberately no per-profile aud.

Sign-in requests name their frontend (app on the login request), and each profile's accepted_audiences is enforced at every minting path — password login, magic-link verify, access-code verify, OAuth callback, invite-accept auto-login, and refresh. Off-audience sign-ins get a stable 403 wrong_application. A profile with no accepted_audiences accepts everyone — the shared/SSO mode — so partitioned and shared frontends are both plain configuration.

For endpoint families that must never serve another app's sessions, declare them app-scoped — this check is enforced, not advisory:

from outlabs_auth.frontend import require_app

@app.get("/console/reports", dependencies=[Depends(require_app(auth, "console"))])
async def console_reports(): ...

Honest semantics: this is level-2 separation — defense in depth, consistency, and audit signal on top of RBAC. A public SPA cannot authenticate its app selector, so azp is provenance plus server-side gating, not credential isolation. If one product's credentials must be inert in another product's trust domain, that's level 3: separate deployments.

Route-contract tests

Declared route templates are promises your frontends must keep. outlabs_auth.frontend.contract asserts them against the real route trees — adapters for Nuxt pages/, TanStack flat-file routes, and route-constants files:

from outlabs_auth.frontend.contract import assert_profile_routes, routes_from_nuxt_pages

def test_portal_routes_exist():
    available = routes_from_nuxt_pages(Path("../customer-portal/app/pages"))
    assert_profile_routes(registry.get("portal"), available)

Keep one per frontend per profile so a renamed frontend route fails your backend suite before it ships a dead link.