Sign in with your LlamaPress.ai Account (SSO) | LlamaPress Cookbook
LlamaPress
Cookbook
Auth Stable

Sign in with your LlamaPress.ai Account (SSO)

Put the "Sign in with your LlamaPress.ai account" button on your Devise sign-in page so the box owner signs in with their llamapress.ai identity instead of a password — one view line, plus the user-provisioning step the gem does not do for you.

Proven on qa-candidate.leo.llamapress.ai view · controller

Sign in with your LlamaPress.ai Account (SSO)

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

Your app ships with a Devise sign-in page. The owner of this box already has a llamapress.ai account. This pattern adds one button — "Sign in with your LlamaPress.ai account" — that signs them into your Rails app using that account. They never type an app password. No password is stored for them, and no secret is stored in your app.

The platform does the hard part. LlamaPress.ai mints a short-lived, single-use login grant, your box redeems it server-to-server, and the llama_bot_rails gem signs the matching Devise user in. Your job is two things: render the button, and make sure a Devise user exists that the grant can map to.

When to use: the box owner (and the teammates LlamaPress authorizes on this instance) are the people who sign in — internal tools, admin apps, client dashboards you operate. When not to: your app's own end users. This button only signs in a LlamaPress.ai user who is authorized on this instance (owner, LlamaPress admin, or assigned operator). It is not a public "Sign in with Google"-style identity provider for your customers. Keep the password form for them.


Before you build: five things must be true

This feature spans your app, the box's LlamaBot runtime, and the mothership. Check all five first — four are on the box, one is not yours to change.

# Requirement How to check
1 LlamaBot ≥ 0.6.0 on this box (it redeems the grant) grep 'kody06/llamabot' docker-compose.yml
2 llamapress-simple ≥ 0.6.4 (ships LlamaBotRails::SsoHelper) docker compose exec -T llamapress ls /rails/vendor/llama_bot_rails/app/helpers/llama_bot_rails/sso_helper.rb
3 users.llamapress_user_guid column exists bin/rails runner 'puts User.column_names.grep(/guid/).inspect'
4 MOTHERSHIP_URL + MOTHERSHIP_INSTANCE_NAME in the Rails container docker compose exec -T llamapress env | grep MOTHERSHIP_
5 LlamaPress.ai has SSO enabled for this box — you cannot do this from the box Ask your LlamaPress contact. Symptom if it is off: the button works but lands you back on the sign-in page.

Requirement 5 is a platform setting (unified_login_mode), not a box setting. It is either canary with your box's name on a list, or version_gated (every box that meets requirement 1). If it is off for your box, build the rest anyway — the button falls back to the plain sign-in page, which is exactly today's behavior, and it starts working the moment the platform enables the box.


The 80/20 in one breath

  1. Check the five requirements above.
  2. Add one line to app/views/devise/sessions/new.html.erb that renders the CTA.
  3. Make sure the Devise user carries the owner's llamapress_user_guid — either ask LlamaPress to provision it, or install the resolver initializer in Layer 2.
  4. Restart the Rails container only if you added the initializer (initializers do not hot-reload; views do).
  5. Load /users/sign_in and confirm the button renders with an href ending in /sso/leo/<your-instance-name>.

Layer 1 — The view (one line)

The button's markup, URL, and frame-detection script all live in the gem, so your view holds a single call. The respond_to? guard matters: on an older image the constant exists without the method, and an unguarded call raises a 500 on your sign-in page — the one page that must never break.

<%# app/views/devise/sessions/new.html.erb — put this ABOVE your password form %>
<%= LlamaBotRails::SsoHelper.respond_to?(:cta_html) ? LlamaBotRails::SsoHelper.cta_html : "" %>

<%# ...your existing Devise email/password form stays exactly as it is... %>

That renders a link to https://llamapress.ai/sso/leo/<instance_name> with target="_top", plus a small script that appends surface=rails_app when the page is not inside the chat's preview iframe. That parameter is what sends the user back to your Rails app after login instead of to the chat.

Use the helper method, not a partial. Boxes run Rails in development mode, where an engine's view paths load lazily — a render "llama_bot_rails/..." is unresolvable for the first several requests after a restart or a wake, so the button would silently vanish exactly when someone is trying to sign in. A class method autoloads on request one.

Want your own styling? Build the link yourself from the same URL, and keep the frame-detection script:

<%# app/views/devise/sessions/new.html.erb — custom-styled variant %>
<% sso_url = LlamaBotRails::SsoHelper.respond_to?(:sign_in_url) && LlamaBotRails::SsoHelper.sign_in_url %>
<% if sso_url.present? %>
  <a id="lp-sso-cta" target="_top" href="<%= sso_url %>" class="btn btn-primary w-full">
    Sign in with your LlamaPress.ai account
  </a>
  <script>
    (function () {
      if (window.self === window.top) {
        var a = document.getElementById("lp-sso-cta");
        if (a) a.href += (a.href.indexOf("?") === -1 ? "?" : "&") + "surface=rails_app";
      }
    })();
  </script>
<% end %>

sign_in_url returns nil when MOTHERSHIP_URL or MOTHERSHIP_INSTANCE_NAME is missing, so a self-hosted copy of your app renders the password form alone. Do not hard-code the URL — a restored or renamed box would keep pointing at the old name.


Layer 2 — The user the grant maps to (the step everyone misses)

The grant identifies the person by a stable GUID, never by email. The gem's default resolver only finds a Devise user that already carries that GUID in llamapress_user_guid. It never creates one, and it never matches on email. On a fresh box every row has NULL there, so the first sign-in redeems the grant, finds nobody, and quietly drops the user back on the sign-in page.

You have two ways to fix it. Pick one.

Option A — ask LlamaPress to provision (no code)

LlamaPress can stamp the GUID onto your Devise user from the admin side ("Provision SSO users" on the instance page). This links the box's authorized users and needs nothing in your app. Best when the app has a small, fixed set of operators.

Option B — create-or-link on first sign-in (an initializer)

Override the resolver so the first successful SSO signs the person in and links them. Drop the file in config/initializers/custom/ — that directory is bind-mounted into the container on current images, so your file is actually loaded.

# config/initializers/custom/unified_login_provisioning.rb
#
# Create-or-link the Devise user on first SSO sign-in.
# Identity is the GUID. Email is used ONCE, only to adopt a user that has no GUID yet.
LlamaBotRails.guid_user_resolver = ->(guid, payload) do
  begin
    next nil if guid.blank?

    user_class = Devise.mappings[Devise.default_scope].to
    next nil unless user_class.column_names.include?("llamapress_user_guid")

    found = user_class.find_by(llamapress_user_guid: guid)
    next found if found

    user_payload = payload["user"] || payload[:user] || {}
    email = (user_payload["email"] || user_payload[:email]).to_s.strip.downcase
    next nil if email.empty?

    # One-time adoption of a hand-created account. GUID-less rows only.
    claimable = user_class.where(llamapress_user_guid: nil).find_by(email: email)
    if claimable
      claimable.update_column(:llamapress_user_guid, guid)
      Rails.logger.info("[unified_login] linked existing user id=#{claimable.id}")
      next claimable
    end

    created = user_class.create!(
      email: email,
      password: SecureRandom.hex(24),   # unusable by design; they sign in via SSO
      llamapress_user_guid: guid
    )
    Rails.logger.info("[unified_login] provisioned new user id=#{created.id}")
    created
  rescue => e
    Rails.logger.error("[unified_login] provisioning failed: #{e.class}: #{e.message}")
    nil   # never raise — a nil here degrades to the normal sign-in page
  end
end

Then restart Rails, because initializers do not hot-reload:

docker compose restart llamapress

Read the email rule before you change it. Only a row with no GUID may be adopted by email, exactly once. After that the GUID wins forever. Matching a GUID-carrying row by email would let a recycled address take over an existing account.

If your app has extra required columns on User (a name, a role, an organization), add them to the create! — otherwise the create raises, the rescue returns nil, and the user lands on the sign-in page with only a log line to show for it.


How the flow actually runs

Browser                     llamapress.ai                     LlamaBot (box)        Your Rails app
  | click the CTA                |                                  |                    |
  |----------------------------->|  is this user authorized on       |                    |
  |                              |  this instance? is SSO on?        |                    |
  |                              |  -> mint grant (5 min, single use)|                    |
  |  302 rails-<box>/llamapress_auth/consume?token=RAW&return_to=/   |                    |
  |---------------------------------------------------------------------------------->|  |
  |                              |     redeem the grant, server-to-server (no secret in Rails)
  |                              |<---------------------------------|<-------------------|
  |                              |  {user: {guid, email, name}, role}------------------->|
  |                              |                                  |  guid_user_resolver -> User
  |                              |                                  |  warden sign-in + session cookie
  |  302 /  — signed in          |                                  |                    |
  |<----------------------------------------------------------------------------------|  |

Your app never holds a mothership credential. It receives a token in a URL, hands it to LlamaBot, and gets back a verified identity.


Gotchas (the hard-won stuff)

  • "It redirects successfully and I am still logged out." This is the default resolver finding nobody. The proof is one line in docker compose logs llamapress: [LlamaBot] unified_login consume: no host user for guid="..." — degrading to /. The HTTP response is a normal 302 with no error — nothing else tells you. Fix it with Layer 2.
  • The endpoint is never allowed to be a wall. Every failure — bad token, expired token, no user — redirects to return_to instead of erroring. That is deliberate, and it means silent failure is the normal failure. Debug from the logs, never from the status code.
  • The button renders but bounces back to sign-in. The box is not enabled for SSO on the platform (requirement 5) or its LlamaBot is below 0.6.0. The grant is never minted, and the fallback is your plain app URL. Nothing is logged on the box — this failure is invisible from your side.
  • Initializers do not hot-reload; views do. Editing the sign-in view is live immediately. Adding or editing the resolver initializer needs docker compose restart llamapress. Verify by rendering, not by reading the file.
  • Only config/initializers/custom/ is a safe place for a new initializer. The container mounts specific initializer files plus that one directory. An initializer you create anywhere else in config/initializers/ exists on disk, greps fine, and is never loaded.
  • Grants last 5 minutes and are single-use. A refreshed or bookmarked consume URL will not work twice, by design. Do not build retry logic around it — send the user back to the CTA, which mints a fresh grant.
  • return_to must be a path, not a URL. The gem rejects anything absolute, protocol-relative, or scheme-bearing and falls back to /. This is an open-redirect guard; do not work around it.
  • Two sign-in pages exist on a box, fed by different config. The chat's own login page reads .leonardo/instance.json; your Rails page reads the MOTHERSHIP_* environment variables. The Rails button can work while the chat button is missing, and the reverse.
  • Do not remove the password form. Nothing about the app's password login changes, and it is your way back in when SSO is unavailable — a sleeping box, a platform setting, an expired grant.
  • Custom domains are out of scope. The flow assumes the box's own rails-<name>.…llamapress.ai host. On a customer domain inside the chat iframe, third-party cookie rules break the session.

Files this pattern touches

app/views/devise/sessions/new.html.erb                     # the CTA (one line)
config/initializers/custom/unified_login_provisioning.rb   # optional: create-or-link on first SSO

Everything else — the route /llamapress_auth/consume, the redemption call, the sign-in itself — ships inside the llama_bot_rails gem. Do not re-implement any of it, and do not add your own route at that path.


Verify it

Run these on the box, in order. Each one answers a different question.

# 1. Does the button render, and does it point at THIS instance?
curl -s http://127.0.0.1:3000/users/sign_in | grep -o 'href="[^"]*sso/leo[^"]*"'

# 2. Does the redemption endpoint exist?
docker compose exec -T llamapress bin/rails runner \
  'puts Rails.application.routes.routes.map { |r| r.path.spec.to_s }.grep(/llamapress_auth/).inspect'

# 3. Is anyone actually linked yet? (all-nil means the first sign-in will fail)
docker compose exec -T llamapress bin/rails runner \
  'puts User.pluck(:id, :email, :llamapress_user_guid).inspect'

# 4. Click the button in a browser, then read what happened:
docker compose logs --tail=50 llamapress | grep -i unified_login

A signed-in session looks like this: the consume request returns 302 and sets a session cookie, and /users/sign_in then redirects away instead of rendering the form.


How to adapt to your schema

  1. Different Devise scope (Admin instead of User): the resolver reads Devise.mappings[Devise.default_scope].to, so it follows your default scope. If SSO should sign in a non-default scope, name that class directly in the resolver.
  2. No llamapress_user_guid column: add it before anything else — add_column :users, :llamapress_user_guid, :string plus a unique index where the value is not null. Without the column the resolver logs a warning and gives up.
  3. Extra required columns on your user: add them to the create!, and give them safe defaults. A validation failure here reads to the user as "SSO does not work".
  4. Roles: the verified payload carries a role (owner, admin, operator, member). Map it to your own roles inside the resolver if your app has them. Trust it — it comes from the platform, not the browser.
  5. Safe to drop: the custom-styled variant in Layer 1 (use cta_html and take the default look), and Option B entirely if LlamaPress provisions your users for you.

Related