Mosaic one model, many lenses

ADR 0028 — Browser SSO (GitHub/Google) + grounded chat context

Two identity/AI improvements for generated apps:

  1. a browser SSO login flow (authorization-code, GitHub and Google) alongside the existing local users and passive OIDC id_token verification;
  2. conversation memory, page grounding, and citation rewriting for the built-in web chat and the vectordb RAG assistant.

1. Browser SSO

Context

Mosaic's identity stack already verifies local JWTs (password login) and, when declared, OIDC id_tokens against a JWKS/public key. But a public portal has no browser login path for an external IdP: no sign-in page, no code exchange, no session cookie. EE's portal uses external SSO for exactly this shape.

Decision

Tessera: identity.sso is a list of providers:

identity:
  sso:
    - provider: github          # github | google
      client_id_env: MOAIC_GITHUB_CLIENT_ID
      client_secret_env: MOAIC_GITHUB_CLIENT_SECRET
      scopes: [read:user, user:email, read:org]
      role_map:
        default: viewer         # fail-closed floor
        rules:                  # first match wins
          - role: admin
            all:
              - claim: login
                in: [eugeis]
          - role: editor
            all:
              - claim: orgs
                contains: mobility-devops
    - provider: google
      client_id_env: MOAIC_GOOGLE_CLIENT_ID
      client_secret_env: MOAIC_GOOGLE_CLIENT_SECRET
      role_map:
        default: viewer
        rules:
          - role: admin
            all:
              - claim: email
                in: [admin@example.com]
  • Supported providers are github and google; anything else is a plan error (sso-provider-unsupported). Duplicates are rejected.
  • Secrets are env-var names only (client_id_env optional, client_secret_env required). The secret itself never appears in the DSL. Missing configuration is a plan error, not a boot surprise.
  • role_map.default is required and must be a role known to the app's IAM model. Rules are evaluated in order; the first rule whose predicates all pass wins. Predicates: claim + exactly one of eq, in, suffix, domain, contains (array claims support contains). A rule may also stamp a realm: {dim: value} onto the issued token.

Generated routes (mounted only when identity.sso is non-empty; the /auth prefix is public):

routebehavior
GET /auth/loginprovider button page
GET /auth/{provider}/login?next=/pathstarts the authorization-code flow (UUID state, 300 s TTL, in-memory)
GET /auth/{provider}/callbackexchanges the code, fetches provider claims, resolves role/realm, issues a local JWT, sets the mosaic_token cookie, redirects to next
GET /auth/logoutclears the cookie and redirects to /auth/login

Provider claim fetch:

  • GitHub: /user, verified primary email from /user/emails, org logins from /user/orgs → claims login, id, name, email, orgs[].
  • Google: OIDC /v1/userinfo → claims sub, email, name, …

The issued JWT carries sub = "{provider}:{provider-subject}", role, optional realm, email, name, and provider. The auth middleware accepts the token from Authorization: Bearer … or the mosaic_token cookie (HttpOnly, SameSite=Lax, Secure under x-forwarded-proto: https). GET /api/auth/me now returns the verified claims instead of trusting x-authz-* headers.

Fail-closed runtime states (pinned by examples/authorized e2e): unknown provider → 404, missing client id → 503, invalid/expired state → 400.

2. Grounded chat context

Context

Both chat surfaces were stateless single-turn calls. EE's agent (f2843c02a) added page grounding via a ChatContext and a configurable chat memory window. The mosaic sync note called this "no gap" because mosaic's chat is generated; this ADR revisits that decision and ports the useful parts.

Decision

Web chat (POST /api/chat, SSR surface) accepts:

{
  "message": "…",
  "conversation_id": "c-…",
  "page_id": "order",
  "citations": {"[1]": "https://example.com/a"}
}

The generated chat module keeps an in-memory per-conversation history (capped at 200 messages) and builds a window from MOAIC_CHAT_HISTORY_{TURNS,MAX_CHARS} (defaults 10 / 24 000). When MOAIC_CHAT_HISTORY_SUMMARIZE=true, dropped older turns are summarized via the configured chat model (MOAIC_CHAT_HISTORY_SUMMARY_MAX_CHARS, default 4000) and injected as an Earlier conversation summary: system message. A non-empty page_id adds a page note to the system prompt; citations are deterministic token→URL rewrites applied to the final answer (LLM and no-LLM fallback). The chat page persists a conversation_id in localStorage and sends it with each message. The response includes conversation_id.

Vectordb RAG assistant (POST /api/assistant/chat, app lens) accepts the same optional fields. Non-streamed and streamed turns now:

  • load the conversation history (MOAIC_ASSISTANT_HISTORY_*, same defaults);
  • optionally summarize dropped turns;
  • retrieve a second page_sources set for page_id and add [page N] context plus the same page note;
  • send the full message list (system + history + question) to the LLM;
  • rewrite citations in the final answer (LLM, streamed, and grounded no-LLM fallback);
  • remember the turn when a conversation_id was sent.

The streamed response emits sources, then page_sources, then delta events, and the final done event carries {answer, sources, page_sources, llm, conversation_id}.

The e2e scenario assistant_chat_accepts_conversation_page_citations pins the no-key fail-soft path: the grounded context is returned and the citation token is rewritten to the caller-supplied URL.

3. mosaic test binary path

The e2e harness spawns the server with the rendered output dir as its working directory. A relative binary path resolved against that cwd, so the harness now canonicalizes the path before spawn.

Consequences

  • Public apps get a real browser sign-in/out flow with provider-specific role mapping, while local users and OIDC id_token verification keep working.
  • All SSO secrets remain deploy env; the DSL carries only provider names, env var names, scopes, and role rules.
  • Chat memory is process-local (in-memory). A multi-replica deployment needs an external store for shared conversations — a follow-up, not part of this ADR.
  • Citation rewriting is deterministic string substitution over caller-provided tokens; it is not a semantic doc→URL resolver.
  • SSO is intentionally limited to GitHub and Google. A generic OIDC authorization-code flow is a possible extension, but the two concrete providers keep the claim-fetch and role-mapping surface small.