Multi-Frontend Support
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
| Piece | What it does |
|---|---|
FrontendProfile | Immutable declaration of one frontend: key, branding, registered origins, per-flow route templates, redirect policy, accepted_audiences |
FrontendProfileRegistry | Startup-validated set of profiles (duplicate keys, non-HTTPS origins, and malformed templates are rejected at wiring time) |
FrontendProfileResolver | The one canonical resolution component — host-supplied resolver over a typed context, resolved once per operation, registered keys only |
profile_id | The resolved key, persisted downstream: mail intents, challenge rows, OAuth state, session records, audit events |
azp claim | Session 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)permitshttp://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.
Related pages
- Passwordless & Messaging — challenge flows, delivery ownership
- OAuth & Social Login — provider routers, association
- Sessions & Audit — token lifecycle and audit events
- Maintainer design record:
docs/MULTI_FRONTEND_SUPPORT.md+ DD-059 in the repo